@pyreon/lint 0.11.10 → 0.12.1

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
@@ -1118,39 +1118,72 @@ function containsJSXReturn(node) {
1118
1118
  }
1119
1119
  return false;
1120
1120
  }
1121
+ /**
1122
+ * Extract destructured property names from an ObjectPattern.
1123
+ * Returns names for the fix suggestion.
1124
+ */
1125
+ function getDestructuredNames(pattern) {
1126
+ if (pattern.type !== "ObjectPattern") return [];
1127
+ const names = [];
1128
+ for (const prop of pattern.properties ?? []) if (prop.type === "ObjectProperty" && prop.key?.type === "Identifier") names.push(prop.key.name);
1129
+ return names;
1130
+ }
1121
1131
  const noPropsDestructure = {
1122
1132
  meta: {
1123
1133
  id: "pyreon/no-props-destructure",
1124
1134
  category: "jsx",
1125
- description: "Disallow destructuring props in component functions — it breaks signal reactivity.",
1135
+ description: "Disallow destructuring props in component functions — breaks reactive prop tracking. Use props.x or splitProps().",
1126
1136
  severity: "error",
1127
1137
  fixable: false
1128
1138
  },
1129
1139
  create(context) {
1140
+ let functionDepth = 0;
1130
1141
  return {
1131
1142
  ArrowFunctionExpression(node) {
1132
- checkFunction(node, context);
1143
+ functionDepth++;
1144
+ checkFunction(node, context, functionDepth);
1145
+ },
1146
+ "ArrowFunctionExpression:exit"() {
1147
+ functionDepth--;
1133
1148
  },
1134
1149
  FunctionDeclaration(node) {
1135
- checkFunction(node, context);
1150
+ functionDepth++;
1151
+ checkFunction(node, context, functionDepth);
1152
+ },
1153
+ "FunctionDeclaration:exit"() {
1154
+ functionDepth--;
1136
1155
  },
1137
1156
  FunctionExpression(node) {
1138
- checkFunction(node, context);
1157
+ functionDepth++;
1158
+ checkFunction(node, context, functionDepth);
1159
+ },
1160
+ "FunctionExpression:exit"() {
1161
+ functionDepth--;
1139
1162
  }
1140
1163
  };
1141
1164
  }
1142
1165
  };
1143
- function checkFunction(node, context) {
1166
+ function checkFunction(node, context, depth) {
1144
1167
  const params = node.params;
1145
1168
  if (!params || params.length === 0) return;
1146
1169
  const firstParam = params[0];
1147
1170
  if (!isDestructuring(firstParam)) return;
1171
+ if (depth > 1) return;
1148
1172
  const body = node.body;
1149
1173
  if (!body) return;
1150
- if (containsJSXReturn(body)) context.report({
1151
- message: "Destructured props in a component function — this breaks signal reactivity. Use `props.x` or `splitProps()` instead.",
1152
- span: getSpan(firstParam)
1153
- });
1174
+ if (containsJSXReturn(body)) {
1175
+ const names = getDestructuredNames(firstParam);
1176
+ const hasRest = (firstParam.properties ?? []).some((p) => p.type === "RestElement");
1177
+ let suggestion = "Use `props.x` pattern for reactive prop access.";
1178
+ if (names.length > 0) {
1179
+ suggestion = `Use \`props\` parameter and access as ${names.map((n) => `props.${n}`).join(", ")}.`;
1180
+ if (hasRest) suggestion += ` For rest props, use \`splitProps(props, [${names.map((n) => `'${n}'`).join(", ")}])\`.`;
1181
+ }
1182
+ context.report({
1183
+ message: `Destructured props in component function — breaks reactive prop tracking. ${suggestion}`,
1184
+ span: getSpan(firstParam)
1185
+ });
1186
+ }
1154
1187
  }
1155
1188
 
1156
1189
  //#endregion
@@ -1700,6 +1733,48 @@ const noSignalInLoop = {
1700
1733
  }
1701
1734
  };
1702
1735
 
1736
+ //#endregion
1737
+ //#region src/rules/reactivity/no-signal-in-props.ts
1738
+ function isComponentTag(name) {
1739
+ return name.length > 0 && name[0] === name[0]?.toUpperCase() && name[0] !== name[0]?.toLowerCase();
1740
+ }
1741
+ /**
1742
+ * Warn when a known signal/computed is called in a component prop position.
1743
+ * Component props are evaluated once at mount — signal reads are NOT reactive
1744
+ * unless the compiler wraps them with _rp(). The compiler handles this
1745
+ * automatically, but this rule catches manual h() calls and educates developers.
1746
+ */
1747
+ const noSignalInProps = {
1748
+ meta: {
1749
+ id: "pyreon/no-signal-in-props",
1750
+ category: "reactivity",
1751
+ description: "Signal call in component prop — value captured once unless compiler wraps it. Use props.x pattern for reactivity.",
1752
+ severity: "warn",
1753
+ fixable: false
1754
+ },
1755
+ create(context) {
1756
+ return { JSXExpressionContainer(node) {
1757
+ const expr = node.expression;
1758
+ if (!expr || expr.type !== "CallExpression") return;
1759
+ const callee = expr.callee;
1760
+ if (!callee || callee.type !== "Identifier") return;
1761
+ const source = context.getSourceText();
1762
+ let i = node.start - 1;
1763
+ while (i >= 0 && source[i] !== "<" && source[i] !== ">") i--;
1764
+ if (i < 0 || source[i] !== "<") return;
1765
+ const tagStart = i + 1;
1766
+ let tagEnd = tagStart;
1767
+ while (tagEnd < source.length && /[\w.]/.test(source[tagEnd] ?? "")) tagEnd++;
1768
+ const tagName = source.slice(tagStart, tagEnd);
1769
+ if (!tagName || !isComponentTag(tagName)) return;
1770
+ context.report({
1771
+ message: `Signal call in <${tagName}> prop — use props.x pattern inside the component for reactive access.`,
1772
+ span: getSpan(expr)
1773
+ });
1774
+ } };
1775
+ }
1776
+ };
1777
+
1703
1778
  //#endregion
1704
1779
  //#region src/rules/reactivity/no-signal-leak.ts
1705
1780
  const noSignalLeak = {
@@ -2418,6 +2493,7 @@ const allRules = [
2418
2493
  noBareSignalInJsx,
2419
2494
  noContextDestructure,
2420
2495
  noSignalInLoop,
2496
+ noSignalInProps,
2421
2497
  noNestedEffect,
2422
2498
  noPeekInTracked,
2423
2499
  noUnbatchedUpdates,
@@ -2659,10 +2735,19 @@ function lintFile(filePath, sourceText, rules, config, cache) {
2659
2735
  allCallbacks.push(rule.create(ctx));
2660
2736
  }
2661
2737
  new Visitor(mergeCallbacks(allCallbacks)).visit(program);
2662
- diagnostics.sort((a, b) => a.span.start - b.span.start);
2738
+ const lines = sourceText.split("\n");
2739
+ const filtered = diagnostics.filter((d) => {
2740
+ const prevLineIdx = d.loc.line - 2;
2741
+ if (prevLineIdx < 0) return true;
2742
+ const prevLine = lines[prevLineIdx]?.trim();
2743
+ if (!prevLine?.startsWith("// pyreon-lint-ignore")) return true;
2744
+ const rest = prevLine.slice(21).trim();
2745
+ return rest.length > 0 && rest !== d.ruleId;
2746
+ });
2747
+ filtered.sort((a, b) => a.span.start - b.span.start);
2663
2748
  return {
2664
2749
  filePath,
2665
- diagnostics
2750
+ diagnostics: filtered
2666
2751
  };
2667
2752
  }
2668
2753
  /**