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