@pyreon/lint 0.14.0 → 0.16.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/README.md +9 -7
- package/bin/pyreon-lint.js +2 -0
- package/lib/analysis/cli.js.html +1 -1
- package/lib/analysis/index.js.html +1 -1
- package/lib/cli.js +791 -69
- package/lib/index.js +791 -69
- package/lib/types/index.d.ts +1 -1
- package/package.json +5 -3
- package/src/manifest.ts +6 -6
- package/src/rules/architecture/dev-guard-warnings.ts +56 -6
- package/src/rules/architecture/no-process-dev-gate.ts +141 -62
- package/src/rules/index.ts +25 -2
- package/src/rules/jsx/no-props-destructure.ts +57 -7
- package/src/rules/lifecycle/no-imperative-effect-on-create.ts +278 -0
- package/src/rules/reactivity/no-async-effect.ts +84 -0
- package/src/rules/reactivity/no-signal-call-write.ts +60 -0
- package/src/rules/reactivity/storage-signal-v-forwarding.ts +184 -0
- package/src/rules/ssg/index.ts +3 -0
- package/src/rules/ssg/invalid-loader-export.ts +84 -0
- package/src/rules/ssg/missing-get-static-paths.ts +103 -0
- package/src/rules/ssg/revalidate-not-pure-literal.ts +69 -0
- package/src/runner.ts +8 -8
- package/src/tests/runner.test.ts +547 -9
- package/src/tests/ssg-rules.test.ts +211 -0
- package/src/tests/storage-signal-v-forwarding.test.ts +224 -0
- package/src/types.ts +1 -0
- package/src/utils/validate-options.ts +1 -1
- package/lib/cli.js.map +0 -1
- package/lib/index.js.map +0 -1
- package/lib/types/index.d.ts.map +0 -1
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
|
-
|
|
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
|
|
819
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
824
|
-
*
|
|
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
|
-
*
|
|
828
|
-
*
|
|
829
|
-
*
|
|
830
|
-
*
|
|
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
|
|
833
|
-
*
|
|
865
|
+
* **The bundler-agnostic standard** (used by React, Vue, Preact, Solid,
|
|
866
|
+
* MobX, Redux):
|
|
834
867
|
*
|
|
835
868
|
* ```ts
|
|
836
|
-
*
|
|
837
|
-
* if (!import.meta.env?.DEV) return
|
|
869
|
+
* if (process.env.NODE_ENV !== 'production') console.warn('...')
|
|
838
870
|
* ```
|
|
839
871
|
*
|
|
840
|
-
*
|
|
841
|
-
*
|
|
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
|
|
846
|
-
*
|
|
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
|
|
850
|
-
* server-only code (Node environments where
|
|
851
|
-
*
|
|
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\"`
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
907
|
-
return false;
|
|
936
|
+
return (right?.type === "Literal" || right?.type === "StringLiteral") && right.value === "production";
|
|
908
937
|
}
|
|
909
|
-
|
|
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
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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 = {
|
|
@@ -2472,6 +2949,99 @@ const preferComputed = {
|
|
|
2472
2949
|
}
|
|
2473
2950
|
};
|
|
2474
2951
|
|
|
2952
|
+
//#endregion
|
|
2953
|
+
//#region src/rules/reactivity/storage-signal-v-forwarding.ts
|
|
2954
|
+
function isDirectDelegation(rhs) {
|
|
2955
|
+
if (!rhs) return false;
|
|
2956
|
+
if (rhs.type === "MemberExpression" && rhs.property?.name === "direct") return true;
|
|
2957
|
+
if (rhs.type === "ArrowFunctionExpression" || rhs.type === "FunctionExpression") {
|
|
2958
|
+
const body = rhs.body;
|
|
2959
|
+
if (!body) return false;
|
|
2960
|
+
if (body.type !== "BlockStatement") return isDirectCall(body);
|
|
2961
|
+
const stmts = body.body;
|
|
2962
|
+
if (!stmts || stmts.length !== 1) return false;
|
|
2963
|
+
const stmt = stmts[0];
|
|
2964
|
+
if (stmt.type !== "ReturnStatement") return false;
|
|
2965
|
+
return isDirectCall(stmt.argument);
|
|
2966
|
+
}
|
|
2967
|
+
return false;
|
|
2968
|
+
}
|
|
2969
|
+
function isDirectCall(expr) {
|
|
2970
|
+
if (!expr || expr.type !== "CallExpression") return false;
|
|
2971
|
+
const callee = expr.callee;
|
|
2972
|
+
return Boolean(callee && callee.type === "MemberExpression" && callee.property?.name === "direct");
|
|
2973
|
+
}
|
|
2974
|
+
function getStringLiteralValue(node) {
|
|
2975
|
+
if (!node) return null;
|
|
2976
|
+
if (node.type === "Literal" && typeof node.value === "string") return node.value;
|
|
2977
|
+
if (node.type === "StringLiteral" && typeof node.value === "string") return node.value;
|
|
2978
|
+
return null;
|
|
2979
|
+
}
|
|
2980
|
+
const storageSignalVForwarding = {
|
|
2981
|
+
meta: {
|
|
2982
|
+
id: "pyreon/storage-signal-v-forwarding",
|
|
2983
|
+
category: "reactivity",
|
|
2984
|
+
description: "Signal-wrapper callables delegating `.direct` to a base signal must also forward the internal `_v` field. Without forwarding, the compiler-emitted `_bindText` fast path reads `undefined` and renders empty text post-hydration.",
|
|
2985
|
+
severity: "error",
|
|
2986
|
+
fixable: false
|
|
2987
|
+
},
|
|
2988
|
+
create(context) {
|
|
2989
|
+
const stack = [];
|
|
2990
|
+
const enter = () => {
|
|
2991
|
+
stack.push({
|
|
2992
|
+
directAssigns: /* @__PURE__ */ new Map(),
|
|
2993
|
+
vForwards: /* @__PURE__ */ new Set()
|
|
2994
|
+
});
|
|
2995
|
+
};
|
|
2996
|
+
const exit = () => {
|
|
2997
|
+
const scope = stack.pop();
|
|
2998
|
+
if (!scope) return;
|
|
2999
|
+
for (const [name, node] of scope.directAssigns) {
|
|
3000
|
+
if (scope.vForwards.has(name)) continue;
|
|
3001
|
+
context.report({
|
|
3002
|
+
message: `Signal wrapper '${name}' delegates \`.direct\` to a base signal but does not forward \`_v\`. The compiler-emitted \`_bindText\` fast path reads \`${name}._v\` directly — without forwarding, the binding writes \`''\` on initial render AND every subscriber notification, even after \`.set()\` calls. Add: \`Object.defineProperty(${name}, '_v', { get: () => sig._v, configurable: true })\` in the same scope. Reference: \`packages/fundamentals/storage/src/local.ts:createStorageSignal\` for the canonical shape.`,
|
|
3003
|
+
span: getSpan(node)
|
|
3004
|
+
});
|
|
3005
|
+
}
|
|
3006
|
+
};
|
|
3007
|
+
return {
|
|
3008
|
+
Program: enter,
|
|
3009
|
+
"Program:exit": exit,
|
|
3010
|
+
FunctionDeclaration: enter,
|
|
3011
|
+
"FunctionDeclaration:exit": exit,
|
|
3012
|
+
FunctionExpression: enter,
|
|
3013
|
+
"FunctionExpression:exit": exit,
|
|
3014
|
+
ArrowFunctionExpression: enter,
|
|
3015
|
+
"ArrowFunctionExpression:exit": exit,
|
|
3016
|
+
AssignmentExpression(node) {
|
|
3017
|
+
const scope = stack[stack.length - 1];
|
|
3018
|
+
if (!scope) return;
|
|
3019
|
+
const left = node.left;
|
|
3020
|
+
if (left?.type !== "MemberExpression") return;
|
|
3021
|
+
if (left.object?.type !== "Identifier") return;
|
|
3022
|
+
const objName = left.object.name;
|
|
3023
|
+
const propName = left.property?.name ?? getStringLiteralValue(left.property);
|
|
3024
|
+
if (propName === "direct" && isDirectDelegation(node.right)) scope.directAssigns.set(objName, node);
|
|
3025
|
+
else if (propName === "_v") scope.vForwards.add(objName);
|
|
3026
|
+
},
|
|
3027
|
+
CallExpression(node) {
|
|
3028
|
+
const scope = stack[stack.length - 1];
|
|
3029
|
+
if (!scope) return;
|
|
3030
|
+
const callee = node.callee;
|
|
3031
|
+
if (!callee || callee.type !== "MemberExpression") return;
|
|
3032
|
+
if (callee.object?.type !== "Identifier" || callee.object.name !== "Object") return;
|
|
3033
|
+
if (callee.property?.name !== "defineProperty") return;
|
|
3034
|
+
const args = node.arguments;
|
|
3035
|
+
if (!args || args.length < 2) return;
|
|
3036
|
+
const target = args[0];
|
|
3037
|
+
if (target?.type !== "Identifier") return;
|
|
3038
|
+
if (getStringLiteralValue(args[1]) !== "_v") return;
|
|
3039
|
+
scope.vForwards.add(target.name);
|
|
3040
|
+
}
|
|
3041
|
+
};
|
|
3042
|
+
}
|
|
3043
|
+
};
|
|
3044
|
+
|
|
2475
3045
|
//#endregion
|
|
2476
3046
|
//#region src/rules/router/no-href-navigation.ts
|
|
2477
3047
|
const EXTERNAL_PREFIXES = [
|
|
@@ -2713,6 +3283,151 @@ function isPathComparison(node) {
|
|
|
2713
3283
|
return false;
|
|
2714
3284
|
}
|
|
2715
3285
|
|
|
3286
|
+
//#endregion
|
|
3287
|
+
//#region src/rules/ssg/invalid-loader-export.ts
|
|
3288
|
+
const ROUTES_PATH_RE$2 = /[/\\]routes[/\\]/;
|
|
3289
|
+
function isLikelyCallable(node) {
|
|
3290
|
+
if (!node) return false;
|
|
3291
|
+
if (node.type === "ArrowFunctionExpression") return true;
|
|
3292
|
+
if (node.type === "FunctionExpression") return true;
|
|
3293
|
+
if (node.type === "Identifier") return true;
|
|
3294
|
+
if (node.type === "CallExpression") return true;
|
|
3295
|
+
if (node.type === "MemberExpression") return true;
|
|
3296
|
+
if (node.type === "TSAsExpression" || node.type === "TSSatisfiesExpression") return isLikelyCallable(node.expression);
|
|
3297
|
+
return false;
|
|
3298
|
+
}
|
|
3299
|
+
const invalidLoaderExport = {
|
|
3300
|
+
meta: {
|
|
3301
|
+
id: "pyreon/invalid-loader-export",
|
|
3302
|
+
category: "ssg",
|
|
3303
|
+
description: "`export const loader` must be a function — non-callable exports crash the SSR runtime with `loader is not a function`.",
|
|
3304
|
+
severity: "error",
|
|
3305
|
+
fixable: false
|
|
3306
|
+
},
|
|
3307
|
+
create(context) {
|
|
3308
|
+
const filePath = context.getFilePath();
|
|
3309
|
+
if (!ROUTES_PATH_RE$2.test(filePath)) return {};
|
|
3310
|
+
return { ExportNamedDeclaration(node) {
|
|
3311
|
+
const decl = node.declaration;
|
|
3312
|
+
if (!decl) return;
|
|
3313
|
+
if (decl.type !== "VariableDeclaration") return;
|
|
3314
|
+
for (const declarator of decl.declarations ?? []) {
|
|
3315
|
+
if (declarator.type !== "VariableDeclarator") continue;
|
|
3316
|
+
const id = declarator.id;
|
|
3317
|
+
if (id?.type !== "Identifier" || id.name !== "loader") continue;
|
|
3318
|
+
const init = declarator.init;
|
|
3319
|
+
if (!init) continue;
|
|
3320
|
+
if (isLikelyCallable(init)) continue;
|
|
3321
|
+
context.report({
|
|
3322
|
+
message: "`export const loader` must be a function (arrow, function expression, or identifier reference). Got a non-callable expression — the SSR runtime will crash with `TypeError: loader is not a function`. If you meant to export static data, use `export const meta = { ... }` instead.",
|
|
3323
|
+
span: getSpan(init)
|
|
3324
|
+
});
|
|
3325
|
+
}
|
|
3326
|
+
} };
|
|
3327
|
+
}
|
|
3328
|
+
};
|
|
3329
|
+
|
|
3330
|
+
//#endregion
|
|
3331
|
+
//#region src/rules/ssg/missing-get-static-paths.ts
|
|
3332
|
+
const ROUTES_PATH_RE$1 = /[/\\]routes[/\\]/;
|
|
3333
|
+
const API_PATH_RE = /[/\\]routes[/\\]api[/\\]/;
|
|
3334
|
+
const DYNAMIC_FILENAME_RE = /\[.+\]\.(tsx?|jsx?)$/;
|
|
3335
|
+
const SPECIAL_ROUTE_RE = /[/\\]_(layout|error|loading|404|not-found)\./;
|
|
3336
|
+
const missingGetStaticPaths = {
|
|
3337
|
+
meta: {
|
|
3338
|
+
id: "pyreon/missing-get-static-paths",
|
|
3339
|
+
category: "ssg",
|
|
3340
|
+
description: "Dynamic route files (`[id].tsx`, `[...slug].tsx`) should export `getStaticPaths` — under `mode: \"ssg\"` the SSG plugin silently skips routes without it.",
|
|
3341
|
+
severity: "warn",
|
|
3342
|
+
fixable: false
|
|
3343
|
+
},
|
|
3344
|
+
create(context) {
|
|
3345
|
+
const filePath = context.getFilePath();
|
|
3346
|
+
if (!ROUTES_PATH_RE$1.test(filePath)) return {};
|
|
3347
|
+
if (API_PATH_RE.test(filePath)) return {};
|
|
3348
|
+
if (!DYNAMIC_FILENAME_RE.test(filePath)) return {};
|
|
3349
|
+
if (SPECIAL_ROUTE_RE.test(filePath)) return {};
|
|
3350
|
+
let hasGetStaticPaths = false;
|
|
3351
|
+
let hasDefaultExport = false;
|
|
3352
|
+
let programSpan = null;
|
|
3353
|
+
return {
|
|
3354
|
+
Program(node) {
|
|
3355
|
+
programSpan = {
|
|
3356
|
+
start: node.start ?? 0,
|
|
3357
|
+
end: node.end ?? 0
|
|
3358
|
+
};
|
|
3359
|
+
},
|
|
3360
|
+
ExportNamedDeclaration(node) {
|
|
3361
|
+
const decl = node.declaration;
|
|
3362
|
+
if (!decl) return;
|
|
3363
|
+
if (decl.type === "VariableDeclaration") for (const declarator of decl.declarations ?? []) {
|
|
3364
|
+
if (declarator.type !== "VariableDeclarator") continue;
|
|
3365
|
+
const id = declarator.id;
|
|
3366
|
+
if (id?.type === "Identifier" && id.name === "getStaticPaths") hasGetStaticPaths = true;
|
|
3367
|
+
}
|
|
3368
|
+
else if (decl.type === "FunctionDeclaration") {
|
|
3369
|
+
if (decl.id?.name === "getStaticPaths") hasGetStaticPaths = true;
|
|
3370
|
+
}
|
|
3371
|
+
},
|
|
3372
|
+
ExportDefaultDeclaration() {
|
|
3373
|
+
hasDefaultExport = true;
|
|
3374
|
+
},
|
|
3375
|
+
"Program:exit"() {
|
|
3376
|
+
if (!hasDefaultExport) return;
|
|
3377
|
+
if (hasGetStaticPaths || !programSpan) return;
|
|
3378
|
+
context.report({
|
|
3379
|
+
message: "Dynamic route file is missing `export const getStaticPaths` — under `mode: \"ssg\"` the SSG plugin silently skips this route, so the dist won't contain prerendered HTML. Either add `export const getStaticPaths = () => [{ params: { ... } }, ...]` enumerating the concrete values, OR declare the route as runtime-only by switching to `mode: \"ssr\"` / `mode: \"isr\"`.",
|
|
3380
|
+
span: {
|
|
3381
|
+
start: programSpan.start,
|
|
3382
|
+
end: Math.min(programSpan.start + 1, programSpan.end)
|
|
3383
|
+
}
|
|
3384
|
+
});
|
|
3385
|
+
}
|
|
3386
|
+
};
|
|
3387
|
+
}
|
|
3388
|
+
};
|
|
3389
|
+
|
|
3390
|
+
//#endregion
|
|
3391
|
+
//#region src/rules/ssg/revalidate-not-pure-literal.ts
|
|
3392
|
+
const ROUTES_PATH_RE = /[/\\]routes[/\\]/;
|
|
3393
|
+
function isLiteralOk(node) {
|
|
3394
|
+
if (!node) return false;
|
|
3395
|
+
if (node.type === "Literal" && typeof node.value === "number") return true;
|
|
3396
|
+
if (node.type === "NumericLiteral" && typeof node.value === "number") return true;
|
|
3397
|
+
if (node.type === "Literal" && node.value === false) return true;
|
|
3398
|
+
if (node.type === "BooleanLiteral" && node.value === false) return true;
|
|
3399
|
+
return false;
|
|
3400
|
+
}
|
|
3401
|
+
const revalidateNotPureLiteral = {
|
|
3402
|
+
meta: {
|
|
3403
|
+
id: "pyreon/revalidate-not-pure-literal",
|
|
3404
|
+
category: "ssg",
|
|
3405
|
+
description: "`export const revalidate = X` must be a numeric literal or `false` — non-literal forms are silently dropped from the build-time ISR manifest (PR I limitation).",
|
|
3406
|
+
severity: "error",
|
|
3407
|
+
fixable: false
|
|
3408
|
+
},
|
|
3409
|
+
create(context) {
|
|
3410
|
+
const filePath = context.getFilePath();
|
|
3411
|
+
if (!ROUTES_PATH_RE.test(filePath)) return {};
|
|
3412
|
+
return { ExportNamedDeclaration(node) {
|
|
3413
|
+
const decl = node.declaration;
|
|
3414
|
+
if (!decl || decl.type !== "VariableDeclaration") return;
|
|
3415
|
+
for (const declarator of decl.declarations ?? []) {
|
|
3416
|
+
if (declarator.type !== "VariableDeclarator") continue;
|
|
3417
|
+
const id = declarator.id;
|
|
3418
|
+
if (id?.type !== "Identifier" || id.name !== "revalidate") continue;
|
|
3419
|
+
const init = declarator.init;
|
|
3420
|
+
if (!init) continue;
|
|
3421
|
+
if (isLiteralOk(init)) continue;
|
|
3422
|
+
context.report({
|
|
3423
|
+
message: "`export const revalidate` must be a numeric literal (e.g. `60`, `3600`) or `false` — non-literal expressions (variable references, math, function calls, template literals) are silently dropped from the build-time ISR manifest. Inline the value: `export const revalidate = 60`.",
|
|
3424
|
+
span: getSpan(init)
|
|
3425
|
+
});
|
|
3426
|
+
}
|
|
3427
|
+
} };
|
|
3428
|
+
}
|
|
3429
|
+
};
|
|
3430
|
+
|
|
2716
3431
|
//#endregion
|
|
2717
3432
|
//#region src/rules/ssr/no-mismatch-risk.ts
|
|
2718
3433
|
const noMismatchRisk = {
|
|
@@ -3303,6 +4018,7 @@ const preferCx = {
|
|
|
3303
4018
|
//#endregion
|
|
3304
4019
|
//#region src/rules/index.ts
|
|
3305
4020
|
const allRules = [
|
|
4021
|
+
noAsyncEffect,
|
|
3306
4022
|
noBareSignalInJsx,
|
|
3307
4023
|
noContextDestructure,
|
|
3308
4024
|
noSignalInLoop,
|
|
@@ -3313,6 +4029,8 @@ const allRules = [
|
|
|
3313
4029
|
preferComputed,
|
|
3314
4030
|
noEffectAssignment,
|
|
3315
4031
|
noSignalLeak,
|
|
4032
|
+
noSignalCallWrite,
|
|
4033
|
+
storageSignalVForwarding,
|
|
3316
4034
|
noMapInJsx,
|
|
3317
4035
|
useByNotKey,
|
|
3318
4036
|
noClassName,
|
|
@@ -3328,6 +4046,7 @@ const allRules = [
|
|
|
3328
4046
|
noMountInEffect,
|
|
3329
4047
|
noEffectInMount,
|
|
3330
4048
|
noDomInSetup,
|
|
4049
|
+
noImperativeEffectOnCreate,
|
|
3331
4050
|
noLargeForWithoutBy,
|
|
3332
4051
|
noEffectInFor,
|
|
3333
4052
|
noEagerImport,
|
|
@@ -3361,7 +4080,10 @@ const allRules = [
|
|
|
3361
4080
|
noHrefNavigation,
|
|
3362
4081
|
noImperativeNavigateInRender,
|
|
3363
4082
|
noMissingFallback,
|
|
3364
|
-
preferUseIsActive
|
|
4083
|
+
preferUseIsActive,
|
|
4084
|
+
invalidLoaderExport,
|
|
4085
|
+
missingGetStaticPaths,
|
|
4086
|
+
revalidateNotPureLiteral
|
|
3365
4087
|
];
|
|
3366
4088
|
|
|
3367
4089
|
//#endregion
|
|
@@ -3599,8 +4321,8 @@ function lintFile(filePath, sourceText, rules, config, cache, configDiagnosticsS
|
|
|
3599
4321
|
const [severity, options] = Array.isArray(entry) ? [entry[0], entry[1] ?? {}] : [entry, {}];
|
|
3600
4322
|
if (severity === "off") continue;
|
|
3601
4323
|
const cacheKey = `${rule.meta.id}::${JSON.stringify(options)}`;
|
|
3602
|
-
let
|
|
3603
|
-
if (!
|
|
4324
|
+
let validation = VALIDATION_CACHE.get(cacheKey);
|
|
4325
|
+
if (!validation) {
|
|
3604
4326
|
const { errors, warnings } = validateRuleOptions(rule, options);
|
|
3605
4327
|
const configDiags = [];
|
|
3606
4328
|
for (const message of warnings) configDiags.push({
|
|
@@ -3613,16 +4335,16 @@ function lintFile(filePath, sourceText, rules, config, cache, configDiagnosticsS
|
|
|
3613
4335
|
severity: "error",
|
|
3614
4336
|
message
|
|
3615
4337
|
});
|
|
3616
|
-
|
|
4338
|
+
validation = {
|
|
3617
4339
|
ok: errors.length === 0,
|
|
3618
4340
|
diagnostics: configDiags
|
|
3619
4341
|
};
|
|
3620
|
-
VALIDATION_CACHE.set(cacheKey,
|
|
4342
|
+
VALIDATION_CACHE.set(cacheKey, validation);
|
|
3621
4343
|
}
|
|
3622
|
-
if (
|
|
3623
|
-
for (const d of
|
|
3624
|
-
} else for (const d of
|
|
3625
|
-
if (!
|
|
4344
|
+
if (validation.diagnostics.length > 0) if (configDiagnosticsSink) {
|
|
4345
|
+
for (const d of validation.diagnostics) if (!configDiagnosticsSink.some((x) => x.ruleId === d.ruleId && x.message === d.message)) configDiagnosticsSink.push(d);
|
|
4346
|
+
} else for (const d of validation.diagnostics) (d.severity === "error" ? console.error : console.warn)(`[pyreon-lint] ${d.message}`);
|
|
4347
|
+
if (!validation.ok) continue;
|
|
3626
4348
|
const ctx = createRuleContext(rule, severity, options, diagnostics, lineIndex, sourceText, filePath);
|
|
3627
4349
|
allCallbacks.push(rule.create(ctx));
|
|
3628
4350
|
}
|