@pyreon/lint 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -539,9 +539,34 @@ const devGuardWarnings = {
539
539
  }
540
540
  return false;
541
541
  }
542
+ function isProcessEnvNodeEnv(node) {
543
+ let n = node;
544
+ if (n?.type === "ChainExpression") n = n.expression;
545
+ if (n?.type !== "MemberExpression") return false;
546
+ if (n.property?.type !== "Identifier" || n.property.name !== "NODE_ENV") return false;
547
+ let envObj = n.object;
548
+ if (envObj?.type === "ChainExpression") envObj = envObj.expression;
549
+ if (envObj?.type !== "MemberExpression") return false;
550
+ if (envObj.property?.type !== "Identifier" || envObj.property.name !== "env") return false;
551
+ const procObj = envObj.object;
552
+ return procObj?.type === "Identifier" && procObj.name === "process";
553
+ }
554
+ function isDevelopmentCheck(node) {
555
+ if (node?.type !== "BinaryExpression") return false;
556
+ if (node.operator !== "!==" && node.operator !== "!=") return false;
557
+ const matches = (a, b) => isProcessEnvNodeEnv(a) && (b?.type === "Literal" || b?.type === "StringLiteral") && b.value === "production";
558
+ return matches(node.left, node.right) || matches(node.right, node.left);
559
+ }
560
+ function isProductionCheck(node) {
561
+ if (node?.type !== "BinaryExpression") return false;
562
+ if (node.operator !== "===" && node.operator !== "==") return false;
563
+ const matches = (a, b) => isProcessEnvNodeEnv(a) && (b?.type === "Literal" || b?.type === "StringLiteral") && b.value === "production";
564
+ return matches(node.left, node.right) || matches(node.right, node.left);
565
+ }
542
566
  function containsDevGuard(test) {
543
567
  if (!test) return false;
544
568
  if (isDevFlag(test)) return true;
569
+ if (isDevelopmentCheck(test)) return true;
545
570
  if (test.type === "LogicalExpression" && test.operator === "&&") return containsDevGuard(test.left) || containsDevGuard(test.right);
546
571
  if (test.type === "BinaryExpression" && (test.operator === "===" || test.operator === "==")) return isDevFlag(test.left) || isDevFlag(test.right);
547
572
  return false;
@@ -549,13 +574,12 @@ const devGuardWarnings = {
549
574
  function isEarlyReturnDevGuard(node) {
550
575
  if (!node || node.type !== "IfStatement") return false;
551
576
  const t = node.test;
577
+ const c = node.consequent;
578
+ if (!(c?.type === "ReturnStatement" || c?.type === "BlockStatement" && c.body.length === 1 && c.body[0]?.type === "ReturnStatement")) return false;
579
+ if (isProductionCheck(t)) return true;
552
580
  const arg = t?.type === "UnaryExpression" && t.operator === "!" ? t.argument : null;
553
581
  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;
582
+ return isDevFlag(arg);
559
583
  }
560
584
  let devGuardDepth = 0;
561
585
  let catchDepth = 0;
@@ -815,40 +839,48 @@ const noErrorWithoutPrefix = {
815
839
  //#endregion
816
840
  //#region src/rules/architecture/no-process-dev-gate.ts
817
841
  /**
818
- * `pyreon/no-process-dev-gate` — flag the broken `typeof process` dev-mode gate
819
- * pattern that is dead code in real Vite browser bundles.
842
+ * `pyreon/no-process-dev-gate` — flag bundler-coupled dev-gate patterns
843
+ * that are dead code or unsupported in some bundlers Pyreon ships to.
820
844
  *
821
- * The pattern this rule catches:
845
+ * Pyreon publishes libraries to npm. Consumers compile those libraries
846
+ * with whatever bundler they use — Vite, Webpack (Next.js), Rolldown,
847
+ * esbuild, Rollup, Parcel, Bun. The framework should not ship dev gates
848
+ * that only fire in one bundler.
822
849
  *
823
- * ```ts
824
- * const __DEV__ = typeof process !== 'undefined' && process.env.NODE_ENV !== 'production'
825
- * ```
850
+ * **Two broken patterns this rule catches:**
851
+ *
852
+ * 1. `typeof process !== 'undefined' && process.env.NODE_ENV !== 'production'`
853
+ * The `typeof process` guard isn't replaced by Vite, evaluates to
854
+ * `false` in the browser, and the whole expression is dead. Wrapped
855
+ * dev warnings never fire for users running Vite browser builds.
826
856
  *
827
- * This works in vitest (Node, `process` is defined) but is **silently dead
828
- * code in real Vite browser bundles** because Vite does not polyfill
829
- * `process` for the client. Every dev warning gated on this constant never
830
- * fires for real users in dev mode.
857
+ * 2. `import.meta.env.DEV` (and the `(import.meta as ViteMeta).env?.DEV`
858
+ * cast variant). Vite/Rolldown literal-replace this at build time, but
859
+ * Webpack/esbuild/Rollup/Parcel/Bun/Node-direct don't. In a Pyreon
860
+ * library shipped to a Next.js (Webpack) app, dev warnings never fire
861
+ * — even in development. PR #200 introduced this pattern as the
862
+ * "correct" replacement for the typeof-process compound; that
863
+ * direction was wrong for library code.
831
864
  *
832
- * The fix is to use `import.meta.env.DEV`, which Vite/Rolldown literal-replace
833
- * at build time:
865
+ * **The bundler-agnostic standard** (used by React, Vue, Preact, Solid,
866
+ * MobX, Redux):
834
867
  *
835
868
  * ```ts
836
- * // No const needed — read directly at the use site so the bundler can fold:
837
- * if (!import.meta.env?.DEV) return
869
+ * if (process.env.NODE_ENV !== 'production') console.warn('...')
838
870
  * ```
839
871
  *
840
- * Vitest sets `import.meta.env.DEV === true` automatically (because it is
841
- * Vite-based), so existing tests continue to pass.
872
+ * Every modern bundler auto-replaces `process.env.NODE_ENV` at consumer
873
+ * build time. No `typeof process` guard needed — bundlers replace the
874
+ * literal regardless of whether `process` is otherwise defined.
842
875
  *
843
876
  * Reference implementation: `packages/fundamentals/flow/src/layout.ts:warnIgnoredOptions`.
844
877
  *
845
- * **Auto-fix**: replaces the assignment with `import.meta.env?.DEV === true`.
846
- * Does NOT delete the const declaration — that has to happen by hand because
847
- * the variable name and downstream usages may need updating in callers.
878
+ * **Auto-fix**: replaces the broken expression with
879
+ * `process.env.NODE_ENV !== 'production'`.
848
880
  *
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`:
881
+ * **Server-only exemption**: projects configure `exemptPaths` per-file
882
+ * for server-only code (Node environments where the typeof-process
883
+ * compound is harmless). Configure in `.pyreonlintrc.json`:
852
884
  *
853
885
  * {
854
886
  * "rules": {
@@ -863,25 +895,16 @@ const noProcessDevGate = {
863
895
  meta: {
864
896
  id: "pyreon/no-process-dev-gate",
865
897
  category: "architecture",
866
- description: "Forbid `typeof process !== \"undefined\" && process.env.NODE_ENV !== \"production\"` as a dev-mode gate. Use `import.meta.env.DEV` instead `typeof process` is dead code in real Vite browser bundles because Vite does not polyfill `process` for the client.",
898
+ description: "Forbid bundler-coupled dev gates: `typeof process !== \"undefined\" && process.env.NODE_ENV !== \"production\"` is dead in Vite browser bundles, and `import.meta.env.DEV` is Vite/Rolldown-only (dead in Webpack/Next.js, esbuild, Rollup, Parcel, Bun). Use bundler-agnostic `process.env.NODE_ENV !== \"production\"` every modern bundler auto-replaces it at consumer build time.",
867
899
  severity: "error",
868
900
  fixable: true
869
901
  },
870
902
  create(context) {
871
903
  if (isTestFile(context.getFilePath())) return {};
872
904
  if (isPathExempt(context)) return {};
905
+ const REPLACEMENT = `process.env.NODE_ENV !== 'production'`;
873
906
  /**
874
- * Match the broken pattern at the AST level. We're looking for any
875
- * `LogicalExpression` whose two sides are:
876
- *
877
- * 1. `typeof process !== 'undefined'` (a UnaryExpression on the LHS
878
- * of a BinaryExpression with operator `!==`)
879
- * 2. `process.env.NODE_ENV !== 'production'` (a MemberExpression on
880
- * the LHS of a BinaryExpression with operator `!==`)
881
- *
882
- * The order can be either way (process check first or NODE_ENV check
883
- * first), and the operator can be `&&` or `||` (we only flag `&&`
884
- * because `||` doesn't make sense as a dev gate).
907
+ * Pattern 1: `typeof process !== 'undefined'`
885
908
  */
886
909
  function isTypeofProcessCheck(node) {
887
910
  if (node?.type !== "BinaryExpression") return false;
@@ -890,39 +913,116 @@ const noProcessDevGate = {
890
913
  const right = node.right;
891
914
  if (left?.type !== "UnaryExpression" || left.operator !== "typeof") return false;
892
915
  if (left.argument?.type !== "Identifier" || left.argument.name !== "process") return false;
893
- if ((right?.type === "Literal" || right?.type === "StringLiteral") && right.value === "undefined") return true;
894
- return false;
916
+ return (right?.type === "Literal" || right?.type === "StringLiteral") && right.value === "undefined";
895
917
  }
918
+ /**
919
+ * Pattern 1: `process.env.NODE_ENV !== 'production'` — also matches
920
+ * optional-chaining variants (`process?.env?.NODE_ENV`,
921
+ * `process.env?.NODE_ENV`). ESTree wraps optional access in a
922
+ * `ChainExpression` whose `.expression` is the underlying
923
+ * `MemberExpression` chain.
924
+ */
896
925
  function isNodeEnvCheck(node) {
897
926
  if (node?.type !== "BinaryExpression") return false;
898
927
  if (node.operator !== "!==" && node.operator !== "!=") return false;
899
- const left = node.left;
928
+ let left = node.left;
900
929
  const right = node.right;
930
+ if (left?.type === "ChainExpression") left = left.expression;
901
931
  if (left?.type !== "MemberExpression") return false;
902
932
  if (left.object?.type !== "MemberExpression") return false;
903
933
  if (left.object.object?.type !== "Identifier" || left.object.object.name !== "process") return false;
904
934
  if (left.object.property?.type !== "Identifier" || left.object.property.name !== "env") return false;
905
935
  if (left.property?.type !== "Identifier" || left.property.name !== "NODE_ENV") return false;
906
- if ((right?.type === "Literal" || right?.type === "StringLiteral") && right.value === "production") return true;
907
- return false;
936
+ return (right?.type === "Literal" || right?.type === "StringLiteral") && right.value === "production";
908
937
  }
909
- function isBrokenDevGate(node) {
938
+ /**
939
+ * Match the typeof-process compound: a `LogicalExpression` whose
940
+ * sides are a typeof-process check + a NODE_ENV check, in either
941
+ * order, joined with `&&`.
942
+ */
943
+ function isTypeofCompound(node) {
910
944
  if (node?.type !== "LogicalExpression") return false;
911
945
  if (node.operator !== "&&") return false;
912
946
  return isTypeofProcessCheck(node.left) && isNodeEnvCheck(node.right) || isNodeEnvCheck(node.left) && isTypeofProcessCheck(node.right);
913
947
  }
914
- return { LogicalExpression(node) {
915
- if (!isBrokenDevGate(node)) return;
916
- const span = getSpan(node);
917
- context.report({
918
- message: "`typeof process !== \"undefined\" && process.env.NODE_ENV !== \"production\"` is dead code in real Vite browser bundles — Vite does not polyfill `process`, so this guard is `false` and any wrapped dev warnings never fire for real users. Use `import.meta.env.DEV` instead, which Vite literal-replaces at build time and tree-shakes correctly in prod. Reference implementation: `packages/fundamentals/flow/src/layout.ts:warnIgnoredOptions`.",
919
- span,
920
- fix: {
948
+ /**
949
+ * Strip layers an `import.meta.env.DEV` access can hide behind:
950
+ * - `ChainExpression` (optional chaining)
951
+ * - `TSAsExpression` / `TSTypeAssertion` (`(import.meta as ViteMeta)`)
952
+ * - `ParenthesizedExpression` (just parens)
953
+ */
954
+ function unwrap(node) {
955
+ let n = node;
956
+ while (n) if (n.type === "ChainExpression") n = n.expression;
957
+ else if (n.type === "TSAsExpression" || n.type === "TSTypeAssertion") n = n.expression;
958
+ else if (n.type === "ParenthesizedExpression") n = n.expression;
959
+ else break;
960
+ return n;
961
+ }
962
+ function isImportMeta(node) {
963
+ const n = unwrap(node);
964
+ if (n?.type !== "MetaProperty") return false;
965
+ const meta = n.meta;
966
+ const prop = n.property;
967
+ return meta?.type === "Identifier" && meta.name === "import" && prop?.type === "Identifier" && prop.name === "meta";
968
+ }
969
+ /**
970
+ * Pattern 2: `import.meta.env.DEV` access (any optional/cast variant).
971
+ * Returns the outermost expression node so the autofix replaces the
972
+ * full `import.meta.env.DEV` access (not just the `.DEV` property).
973
+ */
974
+ function isImportMetaEnvDev(node) {
975
+ const outer = unwrap(node);
976
+ if (outer?.type !== "MemberExpression") return false;
977
+ if (outer.property?.type !== "Identifier" || outer.property.name !== "DEV") return false;
978
+ const envAccess = unwrap(outer.object);
979
+ if (envAccess?.type !== "MemberExpression") return false;
980
+ if (envAccess.property?.type !== "Identifier" || envAccess.property.name !== "env") return false;
981
+ return isImportMeta(envAccess.object);
982
+ }
983
+ const handledNodes = /* @__PURE__ */ new WeakSet();
984
+ return {
985
+ LogicalExpression(node) {
986
+ if (!isTypeofCompound(node)) return;
987
+ const span = getSpan(node);
988
+ context.report({
989
+ message: "`typeof process !== \"undefined\" && process.env.NODE_ENV !== \"production\"` is dead code in real Vite browser bundles — Vite does not polyfill `process`, so the guard is `false` and any wrapped dev warnings never fire. Use the bundler-agnostic `process.env.NODE_ENV !== \"production\"` (no typeof guard) — every modern bundler replaces it at consumer build time. Reference: `packages/fundamentals/flow/src/layout.ts`.",
921
990
  span,
922
- replacement: "import.meta.env?.DEV === true"
923
- }
924
- });
925
- } };
991
+ fix: {
992
+ span,
993
+ replacement: REPLACEMENT
994
+ }
995
+ });
996
+ },
997
+ ChainExpression(node) {
998
+ if (!isImportMetaEnvDev(node)) return;
999
+ const inner = unwrap(node);
1000
+ if (inner) handledNodes.add(inner);
1001
+ const span = getSpan(node);
1002
+ context.report({
1003
+ message: "`import.meta.env.DEV` is Vite/Rolldown-specific. In a Pyreon library shipped to consumers using Webpack (Next.js), esbuild, Rollup, Parcel, or Bun, `import.meta.env.DEV` is undefined and dev warnings never fire — even in development. Use bundler-agnostic `process.env.NODE_ENV !== \"production\"` instead. Reference: `packages/fundamentals/flow/src/layout.ts`.",
1004
+ span,
1005
+ fix: {
1006
+ span,
1007
+ replacement: REPLACEMENT
1008
+ }
1009
+ });
1010
+ },
1011
+ MemberExpression(node) {
1012
+ if (handledNodes.has(node)) return;
1013
+ if (!isImportMetaEnvDev(node)) return;
1014
+ if (node.property?.name !== "DEV") return;
1015
+ const span = getSpan(node);
1016
+ context.report({
1017
+ message: "`import.meta.env.DEV` is Vite/Rolldown-specific. In a Pyreon library shipped to consumers using Webpack (Next.js), esbuild, Rollup, Parcel, or Bun, `import.meta.env.DEV` is undefined and dev warnings never fire — even in development. Use bundler-agnostic `process.env.NODE_ENV !== \"production\"` instead. Reference: `packages/fundamentals/flow/src/layout.ts`.",
1018
+ span,
1019
+ fix: {
1020
+ span,
1021
+ replacement: REPLACEMENT
1022
+ }
1023
+ });
1024
+ }
1025
+ };
926
1026
  }
927
1027
  };
928
1028
 
@@ -1624,20 +1724,51 @@ function getDestructuredNames(pattern) {
1624
1724
  for (const prop of pattern.properties ?? []) if (prop.type === "ObjectProperty" && prop.key?.type === "Identifier") names.push(prop.key.name);
1625
1725
  return names;
1626
1726
  }
1727
+ /**
1728
+ * Names of HOC / factory call expressions whose first-argument render
1729
+ * function takes Pyreon component props. Destructuring inside these IS
1730
+ * a real reactivity bug — same as destructuring at the component
1731
+ * signature directly. Do NOT add this exemption for these.
1732
+ *
1733
+ * The `callArgFns` exemption is intentionally narrow: it only fires for
1734
+ * generic call arguments where the parent call is NOT one of these
1735
+ * known component-shaped factories.
1736
+ */
1737
+ const COMPONENT_FACTORY_NAMES = new Set([
1738
+ "createComponent",
1739
+ "defineComponent",
1740
+ "lazy",
1741
+ "memo",
1742
+ "observer",
1743
+ "forwardRef",
1744
+ "rocketstyle",
1745
+ "styled",
1746
+ "attrs",
1747
+ "kinetic"
1748
+ ]);
1749
+ function isComponentFactoryCall(call) {
1750
+ if (!call || call.type !== "CallExpression") return false;
1751
+ const callee = call.callee;
1752
+ if (!callee) return false;
1753
+ if (callee.type === "Identifier" && COMPONENT_FACTORY_NAMES.has(callee.name)) return true;
1754
+ return false;
1755
+ }
1627
1756
  const noPropsDestructure = {
1628
1757
  meta: {
1629
1758
  id: "pyreon/no-props-destructure",
1630
1759
  category: "jsx",
1631
1760
  description: "Disallow destructuring props in component functions — breaks reactive prop tracking. Use props.x or splitProps().",
1632
1761
  severity: "error",
1633
- fixable: false
1762
+ fixable: false,
1763
+ schema: { exemptPaths: "string[]" }
1634
1764
  },
1635
1765
  create(context) {
1766
+ if (isPathExempt(context)) return {};
1636
1767
  let functionDepth = 0;
1637
- const callArgFns = /* @__PURE__ */ new WeakSet();
1768
+ const callArgFns = /* @__PURE__ */ new WeakMap();
1638
1769
  return {
1639
1770
  CallExpression(node) {
1640
- for (const arg of node.arguments ?? []) if (arg?.type === "ArrowFunctionExpression" || arg?.type === "FunctionExpression" || arg?.type === "FunctionDeclaration") callArgFns.add(arg);
1771
+ for (const arg of node.arguments ?? []) if (arg?.type === "ArrowFunctionExpression" || arg?.type === "FunctionExpression" || arg?.type === "FunctionDeclaration") callArgFns.set(arg, node);
1641
1772
  },
1642
1773
  ArrowFunctionExpression(node) {
1643
1774
  functionDepth++;
@@ -1669,7 +1800,8 @@ function checkFunction(node, context, depth, callArgFns) {
1669
1800
  const firstParam = params[0];
1670
1801
  if (!isDestructuring(firstParam)) return;
1671
1802
  if (depth > 1) return;
1672
- if (callArgFns.has(node)) return;
1803
+ const parentCall = callArgFns.get(node);
1804
+ if (parentCall && !isComponentFactoryCall(parentCall)) return;
1673
1805
  const body = node.body;
1674
1806
  if (!body) return;
1675
1807
  if (containsJSXReturn(body)) {
@@ -1848,6 +1980,228 @@ const noEffectInMount = {
1848
1980
  }
1849
1981
  };
1850
1982
 
1983
+ //#endregion
1984
+ //#region src/rules/lifecycle/no-imperative-effect-on-create.ts
1985
+ /**
1986
+ * Imperative APIs whose presence inside an `effect(() => { ... })`
1987
+ * callback signals that the effect is doing setup work that belongs
1988
+ * in `onMount` — not reactive signal tracking. Calls to these inside
1989
+ * an effect at component body level cause the work to run
1990
+ * synchronously during component setup, which is the bug shape #268
1991
+ * surfaced (per-instance effect allocation under load).
1992
+ *
1993
+ * The list is intentionally narrow: each entry is a pattern that
1994
+ * cannot be a pure reactive read. `fetch(...)` triggers IO,
1995
+ * `setTimeout(fn)` schedules a deferred callback, `addEventListener`
1996
+ * mutates a global. None of these track signals; using `effect()` to
1997
+ * run them per-instance is the bug.
1998
+ *
1999
+ * Do NOT add: signal reads (`.value`, `()`), `console.log`, `Math.X`,
2000
+ * `JSON.X` — those are the legitimate reactive-tracking uses of
2001
+ * `effect()`.
2002
+ */
2003
+ const IMPERATIVE_GLOBAL_CALLS = new Set([
2004
+ "fetch",
2005
+ "setTimeout",
2006
+ "setInterval",
2007
+ "requestAnimationFrame",
2008
+ "requestIdleCallback",
2009
+ "queueMicrotask"
2010
+ ]);
2011
+ const IMPERATIVE_MEMBER_METHODS = new Set([
2012
+ "addEventListener",
2013
+ "removeEventListener",
2014
+ "querySelector",
2015
+ "querySelectorAll",
2016
+ "getElementById",
2017
+ "getElementsByClassName",
2018
+ "getElementsByTagName",
2019
+ "getBoundingClientRect",
2020
+ "getComputedStyle",
2021
+ "focus",
2022
+ "blur",
2023
+ "scrollIntoView",
2024
+ "scrollTo",
2025
+ "scrollBy",
2026
+ "requestFullscreen",
2027
+ "play",
2028
+ "pause"
2029
+ ]);
2030
+ const IMPERATIVE_BROWSER_OBJECTS = new Set([
2031
+ "document",
2032
+ "window",
2033
+ "navigator",
2034
+ "localStorage",
2035
+ "sessionStorage"
2036
+ ]);
2037
+ /**
2038
+ * Constructor names whose presence inside an `effect()` body signals
2039
+ * imperative API setup (observers, workers, network sockets) that
2040
+ * should run from `onMount` — not synchronously per-instance at
2041
+ * component setup time. Observer registration and socket allocation
2042
+ * are unambiguously imperative and never tracked as reactive reads.
2043
+ */
2044
+ const IMPERATIVE_CONSTRUCTORS = new Set([
2045
+ "IntersectionObserver",
2046
+ "ResizeObserver",
2047
+ "MutationObserver",
2048
+ "PerformanceObserver",
2049
+ "Worker",
2050
+ "SharedWorker",
2051
+ "WebSocket",
2052
+ "EventSource",
2053
+ "BroadcastChannel"
2054
+ ]);
2055
+ /**
2056
+ * Returns true when `node` is an immediately-invoked function
2057
+ * expression — i.e. a `CallExpression` whose callee is a function
2058
+ * literal: `(() => { ... })()` or `(function () { ... })()`. The body
2059
+ * runs synchronously at the call site, so for our purposes it should
2060
+ * be walked even though it's structurally a "nested function".
2061
+ *
2062
+ * Parenthesized callees (`(arrow)()`) come through as
2063
+ * `ParenthesizedExpression` wrapping the function — unwrap one level.
2064
+ */
2065
+ function isIIFE(node) {
2066
+ if (!node || node.type !== "CallExpression") return false;
2067
+ let callee = node.callee;
2068
+ if (callee?.type === "ParenthesizedExpression") callee = callee.expression;
2069
+ return callee?.type === "ArrowFunctionExpression" || callee?.type === "FunctionExpression";
2070
+ }
2071
+ /**
2072
+ * Walk the effect callback body and look for imperative patterns.
2073
+ * Returns the first matching node + a short label describing what was
2074
+ * found, or null when the body is pure reactive tracking.
2075
+ *
2076
+ * Stops at nested function boundaries — code inside a nested function
2077
+ * (e.g. an event handler the effect attaches) is deferred-execution
2078
+ * and doesn't run synchronously at effect setup. The exception is
2079
+ * IIFE callees: those run at the call site, so we descend into them.
2080
+ */
2081
+ function findImperativePattern(node, insideIIFE = false) {
2082
+ if (!node || typeof node !== "object") return null;
2083
+ if (!insideIIFE && (node.type === "FunctionExpression" || node.type === "FunctionDeclaration" || node.type === "ArrowFunctionExpression")) return null;
2084
+ if (node.type === "AwaitExpression") return {
2085
+ node,
2086
+ label: "`await` (async work)"
2087
+ };
2088
+ if (node.type === "NewExpression") {
2089
+ const callee = node.callee;
2090
+ if (callee?.type === "Identifier" && IMPERATIVE_CONSTRUCTORS.has(callee.name)) return {
2091
+ node,
2092
+ label: `\`new ${callee.name}(...)\``
2093
+ };
2094
+ }
2095
+ if (node.type === "CallExpression") {
2096
+ const callee = node.callee;
2097
+ if (callee?.type === "Identifier" && IMPERATIVE_GLOBAL_CALLS.has(callee.name)) return {
2098
+ node,
2099
+ label: `\`${callee.name}(...)\``
2100
+ };
2101
+ if (callee?.type === "MemberExpression" && callee.property?.type === "Identifier") {
2102
+ const method = callee.property.name;
2103
+ if (IMPERATIVE_MEMBER_METHODS.has(method)) return {
2104
+ node,
2105
+ label: `\`.${method}(...)\``
2106
+ };
2107
+ if (method === "then" || method === "catch" || method === "finally") return {
2108
+ node,
2109
+ label: `\`.${method}(...)\` (Promise chain)`
2110
+ };
2111
+ const obj = callee.object;
2112
+ if (obj?.type === "Identifier" && IMPERATIVE_BROWSER_OBJECTS.has(obj.name)) return {
2113
+ node,
2114
+ label: `\`${obj.name}.${method}(...)\``
2115
+ };
2116
+ }
2117
+ if (isIIFE(node)) {
2118
+ let calleeFn = callee;
2119
+ if (calleeFn?.type === "ParenthesizedExpression") calleeFn = calleeFn.expression;
2120
+ const body = calleeFn?.body;
2121
+ if (body) {
2122
+ const found = findImperativePattern(body, true);
2123
+ if (found) return found;
2124
+ }
2125
+ }
2126
+ }
2127
+ if (node.type === "MemberExpression" && node.object?.type === "Identifier" && IMPERATIVE_BROWSER_OBJECTS.has(node.object.name) && node.property?.type === "Identifier") return {
2128
+ node,
2129
+ label: `\`${node.object.name}.${node.property.name}\``
2130
+ };
2131
+ for (const key in node) {
2132
+ if (key === "parent" || key === "loc" || key === "range" || key === "type") continue;
2133
+ const value = node[key];
2134
+ if (Array.isArray(value)) for (const child of value) {
2135
+ const found = findImperativePattern(child, false);
2136
+ if (found) return found;
2137
+ }
2138
+ else if (value && typeof value === "object") {
2139
+ const found = findImperativePattern(value, false);
2140
+ if (found) return found;
2141
+ }
2142
+ }
2143
+ return null;
2144
+ }
2145
+ /**
2146
+ * Safe wrapper names — `effect()` calls inside these don't fire
2147
+ * synchronously at component setup, so imperative work in their
2148
+ * callbacks is fine.
2149
+ *
2150
+ * `onMount` / `onUnmount` / `onCleanup` — explicit lifecycle hooks.
2151
+ * `renderEffect` — runs after mount, similar lifecycle.
2152
+ *
2153
+ * `effect` is intentionally NOT in this set — the rule's whole purpose
2154
+ * is to walk an effect's body. A nested effect inside another effect
2155
+ * is a separate problem (`no-nested-effect`), not this rule's concern.
2156
+ */
2157
+ const SAFE_WRAPPER_NAMES = new Set([
2158
+ "onMount",
2159
+ "onUnmount",
2160
+ "onCleanup",
2161
+ "renderEffect"
2162
+ ]);
2163
+ const noImperativeEffectOnCreate = {
2164
+ meta: {
2165
+ id: "pyreon/no-imperative-effect-on-create",
2166
+ category: "lifecycle",
2167
+ description: "Flag `effect()` calls at component body level whose callback does imperative work (DOM access, async/IO, addEventListener, setTimeout) — that work belongs in `onMount`, not in a per-instance reactive effect.",
2168
+ severity: "warn",
2169
+ fixable: false,
2170
+ schema: { exemptPaths: "string[]" }
2171
+ },
2172
+ create(context) {
2173
+ if (isPathExempt(context)) return {};
2174
+ let safeWrapperDepth = 0;
2175
+ return {
2176
+ CallExpression(node) {
2177
+ const callee = node.callee;
2178
+ if (callee?.type === "Identifier") {
2179
+ if (SAFE_WRAPPER_NAMES.has(callee.name)) safeWrapperDepth++;
2180
+ }
2181
+ if (safeWrapperDepth > 0) return;
2182
+ if (!isCallTo(node, "effect")) return;
2183
+ const args = node.arguments;
2184
+ if (!args || args.length === 0) return;
2185
+ const fn = args[0];
2186
+ if (!fn) return;
2187
+ let body = null;
2188
+ if (fn.type === "ArrowFunctionExpression" || fn.type === "FunctionExpression") body = fn.body;
2189
+ if (!body) return;
2190
+ const found = findImperativePattern(body);
2191
+ if (!found) return;
2192
+ context.report({
2193
+ message: `\`effect()\` at component body level contains ${found.label} — imperative work belongs in \`onMount\`. Pyreon's \`effect()\` runs synchronously per instance during component setup; per-instance imperative work (DOM access, IO, scheduling) accumulates O(N) at mount under load (cf. PR #268). Wrap the imperative call in \`onMount(() => { ... })\` and keep \`effect()\` for pure signal-tracking subscriptions.`,
2194
+ span: getSpan(node)
2195
+ });
2196
+ },
2197
+ "CallExpression:exit"(node) {
2198
+ const callee = node.callee;
2199
+ if (callee?.type === "Identifier" && SAFE_WRAPPER_NAMES.has(callee.name)) safeWrapperDepth--;
2200
+ }
2201
+ };
2202
+ }
2203
+ };
2204
+
1851
2205
  //#endregion
1852
2206
  //#region src/rules/lifecycle/no-missing-cleanup.ts
1853
2207
  const NEEDS_CLEANUP = new Set(["setInterval", "addEventListener"]);
@@ -2035,6 +2389,75 @@ const preferShowOverDisplay = {
2035
2389
  }
2036
2390
  };
2037
2391
 
2392
+ //#endregion
2393
+ //#region src/rules/reactivity/no-async-effect.ts
2394
+ /**
2395
+ * Disallow async functions passed to `effect()` / `renderEffect()` /
2396
+ * `computed()` (audit bug #1).
2397
+ *
2398
+ * The reactivity tracking context is the SYNCHRONOUS frame around the
2399
+ * callback's top half. Anything after the first `await` runs detached,
2400
+ * so signal reads on the back side aren't tracked and the
2401
+ * effect/computed won't re-run when those signals change. Common
2402
+ * foot-gun:
2403
+ *
2404
+ * effect(async () => {
2405
+ * const id = userId() // tracked ✓
2406
+ * const data = await fetch(...) // boundary
2407
+ * const name = profile() // NOT tracked ✗ — runs once, never again
2408
+ * setName(name)
2409
+ * })
2410
+ *
2411
+ * `computed(async () => …)` is even worse: the computed's value type
2412
+ * becomes `Computed<Promise<T>>`, which silently breaks every consumer
2413
+ * that expects `Computed<T>`. There's no scenario where async makes
2414
+ * sense for a computed.
2415
+ *
2416
+ * The runtime emits a matching dev-mode console.warn for each call
2417
+ * shape (see `packages/core/reactivity/src/effect.ts` and
2418
+ * `computed.ts`); this lint rule surfaces the warning earlier in the
2419
+ * editor / CI loop, before the code even runs.
2420
+ *
2421
+ * Mitigation patterns:
2422
+ * - Read all tracked signals BEFORE any await, then `await` last.
2423
+ * - Use `watch(source, async (val) => …)` — the source is tracked
2424
+ * synchronously; the async callback runs on changes without
2425
+ * needing tracking continuity.
2426
+ * - Split into two effects: one synchronous (track + dispatch), one
2427
+ * async via the dispatch.
2428
+ * - For async derived state, use `createResource` or a
2429
+ * `signal<Promise<T>>` + `effect` pattern, NOT `computed`.
2430
+ */
2431
+ const REACTIVE_PRIMITIVES = [
2432
+ "effect",
2433
+ "renderEffect",
2434
+ "computed"
2435
+ ];
2436
+ const noAsyncEffect = {
2437
+ meta: {
2438
+ id: "pyreon/no-async-effect",
2439
+ category: "reactivity",
2440
+ description: "Disallow async functions in `effect()` / `renderEffect()` / `computed()` — signal reads after the first await are not tracked.",
2441
+ severity: "error",
2442
+ fixable: false
2443
+ },
2444
+ create(context) {
2445
+ return { CallExpression(node) {
2446
+ const calleeName = REACTIVE_PRIMITIVES.find((n) => isCallTo(node, n));
2447
+ if (!calleeName) return;
2448
+ const arg = node.arguments?.[0];
2449
+ if (!arg) return;
2450
+ if ((arg.type === "ArrowFunctionExpression" || arg.type === "FunctionExpression") && arg.async === true) {
2451
+ const remediation = calleeName === "computed" ? "Use `createResource` for async-derived state, or compute synchronously over a signal that holds the awaited value." : "Read all tracked signals before any await, or use `watch(source, asyncCb)` for async-in-callback patterns.";
2452
+ context.report({
2453
+ message: `${calleeName}() callback is async — signal reads after the first \`await\` are NOT tracked. ${remediation}`,
2454
+ span: getSpan(arg)
2455
+ });
2456
+ }
2457
+ } };
2458
+ }
2459
+ };
2460
+
2038
2461
  //#endregion
2039
2462
  //#region src/rules/reactivity/no-bare-signal-in-jsx.ts
2040
2463
  const SKIP_NAMES = new Set([
@@ -2221,6 +2644,60 @@ const noPeekInTracked = {
2221
2644
  }
2222
2645
  };
2223
2646
 
2647
+ //#endregion
2648
+ //#region src/rules/reactivity/no-signal-call-write.ts
2649
+ /**
2650
+ * Mirrors the D1 MCP detector (`signal-write-as-call`) at lint time so
2651
+ * editors flag `sig(value)` write attempts as the user types them.
2652
+ *
2653
+ * Bindings are collected in a single top-down pass: oxc visits
2654
+ * VariableDeclaration top-down before nested function bodies, and `const`
2655
+ * is in the TDZ before declaration — so a use site never precedes its
2656
+ * binding's visitor. Scope-blind on purpose: shadowing a signal name
2657
+ * with a non-signal in a nested scope is itself unusual, and the
2658
+ * diagnostic points at the exact call so a human can dismiss the rare
2659
+ * false positive.
2660
+ *
2661
+ * Only `const` declarations qualify — `let`/`var` may be reassigned to a
2662
+ * non-signal value, so a use-site call wouldn't be a reliable
2663
+ * signal-write.
2664
+ */
2665
+ const noSignalCallWrite = {
2666
+ meta: {
2667
+ id: "pyreon/no-signal-call-write",
2668
+ category: "reactivity",
2669
+ description: "Disallow `sig(value)` write attempts on signal/computed bindings — `signal()` is the read-only callable. Use `sig.set(value)` or `sig.update(fn)`.",
2670
+ severity: "error",
2671
+ fixable: false
2672
+ },
2673
+ create(context) {
2674
+ const bindings = /* @__PURE__ */ new Set();
2675
+ return {
2676
+ VariableDeclaration(node) {
2677
+ if (node.kind !== "const") return;
2678
+ for (const decl of node.declarations ?? []) {
2679
+ if (decl?.type !== "VariableDeclarator") continue;
2680
+ if (decl.id?.type !== "Identifier") continue;
2681
+ const init = decl.init;
2682
+ if (!init) continue;
2683
+ if (!isCallTo(init, "signal") && !isCallTo(init, "computed")) continue;
2684
+ bindings.add(decl.id.name);
2685
+ }
2686
+ },
2687
+ CallExpression(node) {
2688
+ const callee = node.callee;
2689
+ if (!callee || callee.type !== "Identifier") return;
2690
+ if (!bindings.has(callee.name)) return;
2691
+ if (!node.arguments || node.arguments.length === 0) return;
2692
+ context.report({
2693
+ message: `\`${callee.name}(value)\` does NOT write the signal — \`signal()\` is the read-only callable surface and ignores its arguments. Use \`${callee.name}.set(value)\` or \`${callee.name}.update((prev) => …)\`.`,
2694
+ span: getSpan(node)
2695
+ });
2696
+ }
2697
+ };
2698
+ }
2699
+ };
2700
+
2224
2701
  //#endregion
2225
2702
  //#region src/rules/reactivity/no-signal-in-loop.ts
2226
2703
  const noSignalInLoop = {
@@ -3303,6 +3780,7 @@ const preferCx = {
3303
3780
  //#endregion
3304
3781
  //#region src/rules/index.ts
3305
3782
  const allRules = [
3783
+ noAsyncEffect,
3306
3784
  noBareSignalInJsx,
3307
3785
  noContextDestructure,
3308
3786
  noSignalInLoop,
@@ -3313,6 +3791,7 @@ const allRules = [
3313
3791
  preferComputed,
3314
3792
  noEffectAssignment,
3315
3793
  noSignalLeak,
3794
+ noSignalCallWrite,
3316
3795
  noMapInJsx,
3317
3796
  useByNotKey,
3318
3797
  noClassName,
@@ -3328,6 +3807,7 @@ const allRules = [
3328
3807
  noMountInEffect,
3329
3808
  noEffectInMount,
3330
3809
  noDomInSetup,
3810
+ noImperativeEffectOnCreate,
3331
3811
  noLargeForWithoutBy,
3332
3812
  noEffectInFor,
3333
3813
  noEagerImport,