@pyreon/lint 0.11.3 → 0.11.5
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/analysis/cli.js.html +5406 -0
- package/lib/analysis/index.js.html +1 -1
- package/lib/cli.js +3077 -0
- package/lib/cli.js.map +1 -0
- package/lib/index.js +13 -28
- package/lib/index.js.map +1 -1
- package/lib/types/index.d.ts.map +1 -1
- package/package.json +5 -5
- package/src/cli.ts +2 -1
- package/src/lint.ts +1 -7
- package/src/runner.ts +1 -2
- package/src/utils/index.ts +9 -0
- package/src/watcher.ts +1 -7
package/lib/cli.js
ADDED
|
@@ -0,0 +1,3077 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync, readFileSync, readdirSync, statSync, watch, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
4
|
+
import { Visitor, parseSync } from "oxc-parser";
|
|
5
|
+
|
|
6
|
+
//#region src/cache.ts
|
|
7
|
+
/**
|
|
8
|
+
* Simple in-memory cache for parsed ASTs keyed by file content hash.
|
|
9
|
+
*
|
|
10
|
+
* Uses FNV-1a hash of source text as cache key for fast lookups
|
|
11
|
+
* during repeat runs (e.g., watch mode).
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```ts
|
|
15
|
+
* import { AstCache } from "@pyreon/lint"
|
|
16
|
+
*
|
|
17
|
+
* const cache = new AstCache()
|
|
18
|
+
* const cached = cache.get(sourceText)
|
|
19
|
+
* if (!cached) {
|
|
20
|
+
* const parsed = parse(sourceText)
|
|
21
|
+
* cache.set(sourceText, parsed)
|
|
22
|
+
* }
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
var AstCache = class {
|
|
26
|
+
cache = /* @__PURE__ */ new Map();
|
|
27
|
+
get(sourceText) {
|
|
28
|
+
const key = fnv1aHash(sourceText);
|
|
29
|
+
return this.cache.get(key);
|
|
30
|
+
}
|
|
31
|
+
set(sourceText, value) {
|
|
32
|
+
const key = fnv1aHash(sourceText);
|
|
33
|
+
this.cache.set(key, value);
|
|
34
|
+
}
|
|
35
|
+
clear() {
|
|
36
|
+
this.cache.clear();
|
|
37
|
+
}
|
|
38
|
+
get size() {
|
|
39
|
+
return this.cache.size;
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
/** FNV-1a hash — fast, non-cryptographic, low collision rate. */
|
|
43
|
+
function fnv1aHash(str) {
|
|
44
|
+
let hash = 2166136261;
|
|
45
|
+
for (let i = 0; i < str.length; i++) {
|
|
46
|
+
hash ^= str.charCodeAt(i);
|
|
47
|
+
hash = hash * 16777619 | 0;
|
|
48
|
+
}
|
|
49
|
+
return (hash >>> 0).toString(36);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
//#endregion
|
|
53
|
+
//#region src/config/ignore.ts
|
|
54
|
+
/**
|
|
55
|
+
* Create a filter function that returns true if a file path should be ignored.
|
|
56
|
+
*
|
|
57
|
+
* Loads patterns from `.pyreonlintignore` and `.gitignore` in the given directory.
|
|
58
|
+
*
|
|
59
|
+
* @example
|
|
60
|
+
* ```ts
|
|
61
|
+
* import { createIgnoreFilter } from "@pyreon/lint"
|
|
62
|
+
*
|
|
63
|
+
* const isIgnored = createIgnoreFilter(process.cwd())
|
|
64
|
+
* if (!isIgnored("src/app.tsx")) lintFile(...)
|
|
65
|
+
* ```
|
|
66
|
+
*/
|
|
67
|
+
function createIgnoreFilter(cwd, extraIgnore) {
|
|
68
|
+
const patterns = [];
|
|
69
|
+
const resolvedCwd = resolve(cwd);
|
|
70
|
+
loadPatternsFromFile(join(resolvedCwd, ".pyreonlintignore"), patterns);
|
|
71
|
+
loadPatternsFromFile(join(resolvedCwd, ".gitignore"), patterns);
|
|
72
|
+
if (extraIgnore) loadPatternsFromFile(resolve(extraIgnore), patterns);
|
|
73
|
+
const matchers = patterns.map((p) => compileMatcher(p));
|
|
74
|
+
return (filePath) => {
|
|
75
|
+
const normalized = relative(resolvedCwd, resolve(filePath)).replace(/\\/g, "/");
|
|
76
|
+
for (const matcher of matchers) if (matcher(normalized)) return true;
|
|
77
|
+
return false;
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
function loadPatternsFromFile(filePath, patterns) {
|
|
81
|
+
if (!existsSync(filePath)) return;
|
|
82
|
+
try {
|
|
83
|
+
const content = readFileSync(filePath, "utf-8");
|
|
84
|
+
for (const line of content.split("\n")) {
|
|
85
|
+
const trimmed = line.trim();
|
|
86
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
87
|
+
patterns.push(trimmed);
|
|
88
|
+
}
|
|
89
|
+
} catch {}
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Compile a gitignore-style pattern into a matcher function.
|
|
93
|
+
* Supports: `*` (any non-slash chars), `**` (any path segment), `?` (single char),
|
|
94
|
+
* leading `/` (root-anchored), trailing `/` (directory only).
|
|
95
|
+
*/
|
|
96
|
+
function compileMatcher(pattern) {
|
|
97
|
+
let p = pattern;
|
|
98
|
+
let anchored = false;
|
|
99
|
+
if (p.startsWith("!")) return () => false;
|
|
100
|
+
if (p.startsWith("/")) {
|
|
101
|
+
anchored = true;
|
|
102
|
+
p = p.slice(1);
|
|
103
|
+
}
|
|
104
|
+
let dirOnly = false;
|
|
105
|
+
if (p.endsWith("/")) {
|
|
106
|
+
dirOnly = true;
|
|
107
|
+
p = p.slice(0, -1);
|
|
108
|
+
}
|
|
109
|
+
const regex = globToRegex(p);
|
|
110
|
+
return (path) => {
|
|
111
|
+
if (dirOnly) {
|
|
112
|
+
if (anchored) return regex.test(path) || path.startsWith(`${p}/`) || path === p;
|
|
113
|
+
return regex.test(path) || path.includes(`/${p}/`) || path.startsWith(`${p}/`) || path === p;
|
|
114
|
+
}
|
|
115
|
+
if (anchored) return regex.test(path);
|
|
116
|
+
if (regex.test(path)) return true;
|
|
117
|
+
const lastSlash = path.lastIndexOf("/");
|
|
118
|
+
if (lastSlash !== -1) {
|
|
119
|
+
const basename = path.slice(lastSlash + 1);
|
|
120
|
+
return regex.test(basename);
|
|
121
|
+
}
|
|
122
|
+
return false;
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
const GLOB_CHAR_MAP = {
|
|
126
|
+
"?": "[^/]",
|
|
127
|
+
".": "\\.",
|
|
128
|
+
"/": "/"
|
|
129
|
+
};
|
|
130
|
+
function handleStar(glob, pos) {
|
|
131
|
+
if (glob[pos + 1] === "*") {
|
|
132
|
+
if (glob[pos + 2] === "/") return {
|
|
133
|
+
pattern: "(?:.*/)?",
|
|
134
|
+
advance: 3
|
|
135
|
+
};
|
|
136
|
+
return {
|
|
137
|
+
pattern: ".*",
|
|
138
|
+
advance: 2
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
pattern: "[^/]*",
|
|
143
|
+
advance: 1
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
function globToRegex(glob) {
|
|
147
|
+
let result = "^";
|
|
148
|
+
let i = 0;
|
|
149
|
+
while (i < glob.length) {
|
|
150
|
+
const ch = glob[i];
|
|
151
|
+
if (ch === "*") {
|
|
152
|
+
const star = handleStar(glob, i);
|
|
153
|
+
result += star.pattern;
|
|
154
|
+
i += star.advance;
|
|
155
|
+
} else {
|
|
156
|
+
result += GLOB_CHAR_MAP[ch] ?? escapeRegex(ch);
|
|
157
|
+
i++;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
result += "$";
|
|
161
|
+
return new RegExp(result);
|
|
162
|
+
}
|
|
163
|
+
function escapeRegex(str) {
|
|
164
|
+
return str.replace(/[\\^$+{}[\]|()]/g, "\\$&");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
//#endregion
|
|
168
|
+
//#region src/config/loader.ts
|
|
169
|
+
const CONFIG_FILENAMES = [
|
|
170
|
+
".pyreonlintrc.json",
|
|
171
|
+
".pyreonlintrc",
|
|
172
|
+
"pyreonlint.config.json"
|
|
173
|
+
];
|
|
174
|
+
/**
|
|
175
|
+
* Load a lint config file from the given directory or its parents.
|
|
176
|
+
*
|
|
177
|
+
* Search order:
|
|
178
|
+
* 1. `.pyreonlintrc.json`
|
|
179
|
+
* 2. `.pyreonlintrc`
|
|
180
|
+
* 3. `pyreonlint.config.json`
|
|
181
|
+
* 4. `package.json` `"pyreonlint"` field
|
|
182
|
+
*
|
|
183
|
+
* @example
|
|
184
|
+
* ```ts
|
|
185
|
+
* import { loadConfig } from "@pyreon/lint"
|
|
186
|
+
*
|
|
187
|
+
* const config = loadConfig(process.cwd())
|
|
188
|
+
* if (config) console.log(config.preset)
|
|
189
|
+
* ```
|
|
190
|
+
*/
|
|
191
|
+
function loadConfig(cwd) {
|
|
192
|
+
let dir = resolve(cwd);
|
|
193
|
+
const root = dirname(dir);
|
|
194
|
+
while (true) {
|
|
195
|
+
const found = searchDirectory(dir);
|
|
196
|
+
if (found !== null) return found;
|
|
197
|
+
const parent = dirname(dir);
|
|
198
|
+
if (parent === dir || parent === root) break;
|
|
199
|
+
dir = parent;
|
|
200
|
+
}
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
function searchDirectory(dir) {
|
|
204
|
+
for (const filename of CONFIG_FILENAMES) {
|
|
205
|
+
const content = tryReadJson(join(dir, filename));
|
|
206
|
+
if (content !== null) return content;
|
|
207
|
+
}
|
|
208
|
+
return extractPkgConfig(join(dir, "package.json"));
|
|
209
|
+
}
|
|
210
|
+
function extractPkgConfig(pkgPath) {
|
|
211
|
+
const pkg = tryReadJson(pkgPath);
|
|
212
|
+
if (pkg === null || typeof pkg !== "object" || !("pyreonlint" in pkg)) return null;
|
|
213
|
+
const field = pkg.pyreonlint;
|
|
214
|
+
if (field && typeof field === "object") return field;
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Load a config file from a specific path.
|
|
219
|
+
*/
|
|
220
|
+
function loadConfigFromPath(filePath) {
|
|
221
|
+
return tryReadJson(resolve(filePath));
|
|
222
|
+
}
|
|
223
|
+
function tryReadJson(filePath) {
|
|
224
|
+
if (!existsSync(filePath)) return null;
|
|
225
|
+
try {
|
|
226
|
+
const raw = readFileSync(filePath, "utf-8").trim();
|
|
227
|
+
if (!raw) return null;
|
|
228
|
+
return JSON.parse(raw);
|
|
229
|
+
} catch {
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
//#endregion
|
|
235
|
+
//#region src/utils/imports.ts
|
|
236
|
+
const PYREON_PREFIX = "@pyreon/";
|
|
237
|
+
const HEAVY_PACKAGES = new Set([
|
|
238
|
+
"@pyreon/charts",
|
|
239
|
+
"@pyreon/code",
|
|
240
|
+
"@pyreon/document",
|
|
241
|
+
"@pyreon/flow"
|
|
242
|
+
]);
|
|
243
|
+
const BROWSER_GLOBALS = new Set([
|
|
244
|
+
"window",
|
|
245
|
+
"document",
|
|
246
|
+
"navigator",
|
|
247
|
+
"location",
|
|
248
|
+
"history",
|
|
249
|
+
"localStorage",
|
|
250
|
+
"sessionStorage",
|
|
251
|
+
"indexedDB",
|
|
252
|
+
"fetch",
|
|
253
|
+
"XMLHttpRequest",
|
|
254
|
+
"WebSocket",
|
|
255
|
+
"requestAnimationFrame",
|
|
256
|
+
"cancelAnimationFrame",
|
|
257
|
+
"IntersectionObserver",
|
|
258
|
+
"MutationObserver",
|
|
259
|
+
"ResizeObserver",
|
|
260
|
+
"matchMedia",
|
|
261
|
+
"getComputedStyle",
|
|
262
|
+
"addEventListener",
|
|
263
|
+
"removeEventListener"
|
|
264
|
+
]);
|
|
265
|
+
function isPyreonImport(source) {
|
|
266
|
+
return source.startsWith(PYREON_PREFIX);
|
|
267
|
+
}
|
|
268
|
+
function extractImportInfo(node) {
|
|
269
|
+
if (node.type !== "ImportDeclaration") return null;
|
|
270
|
+
const source = node.source?.value;
|
|
271
|
+
if (!source) return null;
|
|
272
|
+
const specifiers = [];
|
|
273
|
+
let isDefault = false;
|
|
274
|
+
let isNamespace = false;
|
|
275
|
+
for (const spec of node.specifiers ?? []) if (spec.type === "ImportDefaultSpecifier") {
|
|
276
|
+
isDefault = true;
|
|
277
|
+
specifiers.push({
|
|
278
|
+
imported: "default",
|
|
279
|
+
local: spec.local?.name ?? ""
|
|
280
|
+
});
|
|
281
|
+
} else if (spec.type === "ImportNamespaceSpecifier") {
|
|
282
|
+
isNamespace = true;
|
|
283
|
+
specifiers.push({
|
|
284
|
+
imported: "*",
|
|
285
|
+
local: spec.local?.name ?? ""
|
|
286
|
+
});
|
|
287
|
+
} else if (spec.type === "ImportSpecifier") {
|
|
288
|
+
const imported = spec.imported?.type === "Identifier" ? spec.imported.name : spec.imported?.value ?? "";
|
|
289
|
+
specifiers.push({
|
|
290
|
+
imported,
|
|
291
|
+
local: spec.local?.name ?? ""
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
return {
|
|
295
|
+
source,
|
|
296
|
+
specifiers,
|
|
297
|
+
isDefault,
|
|
298
|
+
isNamespace
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
//#endregion
|
|
303
|
+
//#region src/utils/ast.ts
|
|
304
|
+
/** Check if a node is a call expression to a specific function name. */
|
|
305
|
+
function isCallTo(node, name) {
|
|
306
|
+
return node.type === "CallExpression" && node.callee?.type === "Identifier" && node.callee.name === name;
|
|
307
|
+
}
|
|
308
|
+
/** Check if a node is a member call like `obj.method()`. */
|
|
309
|
+
function isMemberCallTo(node, objectName, methodName) {
|
|
310
|
+
return node.type === "CallExpression" && node.callee?.type === "MemberExpression" && node.callee.object?.type === "Identifier" && node.callee.object.name === objectName && node.callee.property?.type === "Identifier" && node.callee.property.name === methodName;
|
|
311
|
+
}
|
|
312
|
+
/** Get a JSX attribute by name from an opening element. */
|
|
313
|
+
function getJSXAttribute(openingElement, attrName) {
|
|
314
|
+
const attrs = openingElement.attributes ?? [];
|
|
315
|
+
for (const attr of attrs) if (attr.type === "JSXAttribute" && attr.name?.type === "JSXIdentifier" && attr.name.name === attrName) return attr;
|
|
316
|
+
return null;
|
|
317
|
+
}
|
|
318
|
+
/** Check if a JSX opening element has an attribute. */
|
|
319
|
+
function hasJSXAttribute(openingElement, attrName) {
|
|
320
|
+
return getJSXAttribute(openingElement, attrName) !== null;
|
|
321
|
+
}
|
|
322
|
+
/** Check if a node is an array .map() call. */
|
|
323
|
+
function isArrayMapCall(node) {
|
|
324
|
+
return node.type === "CallExpression" && node.callee?.type === "MemberExpression" && node.callee.property?.type === "Identifier" && node.callee.property.name === "map";
|
|
325
|
+
}
|
|
326
|
+
/** Check if a node is a destructuring pattern. */
|
|
327
|
+
function isDestructuring(node) {
|
|
328
|
+
return node.type === "ObjectPattern" || node.type === "ArrayPattern";
|
|
329
|
+
}
|
|
330
|
+
/** Check if a node is a ternary with JSX in either branch. */
|
|
331
|
+
function isTernaryWithJSX(node) {
|
|
332
|
+
if (node.type !== "ConditionalExpression") return false;
|
|
333
|
+
return containsJSX(node.consequent) || containsJSX(node.alternate);
|
|
334
|
+
}
|
|
335
|
+
/** Check if a node contains JSX anywhere. */
|
|
336
|
+
function containsJSX(node) {
|
|
337
|
+
if (!node) return false;
|
|
338
|
+
if (node.type === "JSXElement" || node.type === "JSXFragment") return true;
|
|
339
|
+
if (node.type === "ParenthesizedExpression") return containsJSX(node.expression);
|
|
340
|
+
return false;
|
|
341
|
+
}
|
|
342
|
+
/** Check if a node is a logical AND with JSX. */
|
|
343
|
+
function isLogicalAndWithJSX(node) {
|
|
344
|
+
if (node.type !== "LogicalExpression" || node.operator !== "&&") return false;
|
|
345
|
+
return containsJSX(node.right);
|
|
346
|
+
}
|
|
347
|
+
/** Check if a node is a .peek() call. */
|
|
348
|
+
function isPeekCall(node) {
|
|
349
|
+
return node.type === "CallExpression" && node.callee?.type === "MemberExpression" && node.callee.property?.type === "Identifier" && node.callee.property.name === "peek";
|
|
350
|
+
}
|
|
351
|
+
/** Check if a node is a .set() call. */
|
|
352
|
+
function isSetCall(node) {
|
|
353
|
+
return node.type === "CallExpression" && node.callee?.type === "MemberExpression" && node.callee.property?.type === "Identifier" && node.callee.property.name === "set";
|
|
354
|
+
}
|
|
355
|
+
/** Get the span (byte offsets) of a node. */
|
|
356
|
+
function getSpan(node) {
|
|
357
|
+
return {
|
|
358
|
+
start: node.start,
|
|
359
|
+
end: node.end
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
//#endregion
|
|
364
|
+
//#region src/rules/accessibility/dialog-a11y.ts
|
|
365
|
+
const dialogA11y = {
|
|
366
|
+
meta: {
|
|
367
|
+
id: "pyreon/dialog-a11y",
|
|
368
|
+
category: "accessibility",
|
|
369
|
+
description: "Warn when <dialog> is missing aria-label or aria-labelledby.",
|
|
370
|
+
severity: "warn",
|
|
371
|
+
fixable: false
|
|
372
|
+
},
|
|
373
|
+
create(context) {
|
|
374
|
+
return { JSXOpeningElement(node) {
|
|
375
|
+
const name = node.name;
|
|
376
|
+
if (!name || name.type !== "JSXIdentifier" || name.name !== "dialog") return;
|
|
377
|
+
const hasLabel = hasJSXAttribute(node, "aria-label");
|
|
378
|
+
const hasLabelledBy = hasJSXAttribute(node, "aria-labelledby");
|
|
379
|
+
if (!hasLabel && !hasLabelledBy) context.report({
|
|
380
|
+
message: "`<dialog>` missing `aria-label` or `aria-labelledby` — provide an accessible label for screen readers.",
|
|
381
|
+
span: getSpan(node)
|
|
382
|
+
});
|
|
383
|
+
} };
|
|
384
|
+
}
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
//#endregion
|
|
388
|
+
//#region src/rules/accessibility/overlay-a11y.ts
|
|
389
|
+
const overlayA11y = {
|
|
390
|
+
meta: {
|
|
391
|
+
id: "pyreon/overlay-a11y",
|
|
392
|
+
category: "accessibility",
|
|
393
|
+
description: "Warn when <Overlay> is missing role, aria-label, or aria-labelledby.",
|
|
394
|
+
severity: "warn",
|
|
395
|
+
fixable: false
|
|
396
|
+
},
|
|
397
|
+
create(context) {
|
|
398
|
+
return { JSXOpeningElement(node) {
|
|
399
|
+
const name = node.name;
|
|
400
|
+
if (!name || name.type !== "JSXIdentifier" || name.name !== "Overlay") return;
|
|
401
|
+
const hasRole = hasJSXAttribute(node, "role");
|
|
402
|
+
const hasLabel = hasJSXAttribute(node, "aria-label");
|
|
403
|
+
const hasLabelledBy = hasJSXAttribute(node, "aria-labelledby");
|
|
404
|
+
if (!hasRole && !hasLabel && !hasLabelledBy) context.report({
|
|
405
|
+
message: "`<Overlay>` missing `role`, `aria-label`, or `aria-labelledby` — provide accessibility attributes for screen readers.",
|
|
406
|
+
span: getSpan(node)
|
|
407
|
+
});
|
|
408
|
+
} };
|
|
409
|
+
}
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
//#endregion
|
|
413
|
+
//#region src/rules/accessibility/toast-a11y.ts
|
|
414
|
+
const toastA11y = {
|
|
415
|
+
meta: {
|
|
416
|
+
id: "pyreon/toast-a11y",
|
|
417
|
+
category: "accessibility",
|
|
418
|
+
description: "Warn when toast-like components are missing role or aria-live attributes.",
|
|
419
|
+
severity: "warn",
|
|
420
|
+
fixable: false
|
|
421
|
+
},
|
|
422
|
+
create(context) {
|
|
423
|
+
return { JSXOpeningElement(node) {
|
|
424
|
+
const name = node.name;
|
|
425
|
+
if (!name || name.type !== "JSXIdentifier") return;
|
|
426
|
+
const tagName = name.name;
|
|
427
|
+
if (tagName === "Toaster") return;
|
|
428
|
+
const firstChar = tagName[0];
|
|
429
|
+
if (!firstChar || firstChar !== firstChar.toUpperCase()) return;
|
|
430
|
+
if (!tagName.toLowerCase().includes("toast")) return;
|
|
431
|
+
const hasRole = hasJSXAttribute(node, "role");
|
|
432
|
+
const hasAriaLive = hasJSXAttribute(node, "aria-live");
|
|
433
|
+
if (!hasRole && !hasAriaLive) context.report({
|
|
434
|
+
message: `Toast component \`<${tagName}>\` missing \`role\` or \`aria-live\` — add \`role="alert"\` and \`aria-live="polite"\` for screen reader accessibility.`,
|
|
435
|
+
span: getSpan(node)
|
|
436
|
+
});
|
|
437
|
+
} };
|
|
438
|
+
}
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
//#endregion
|
|
442
|
+
//#region src/rules/architecture/dev-guard-warnings.ts
|
|
443
|
+
const devGuardWarnings = {
|
|
444
|
+
meta: {
|
|
445
|
+
id: "pyreon/dev-guard-warnings",
|
|
446
|
+
category: "architecture",
|
|
447
|
+
description: "Require console.warn/error calls to be wrapped in `if (__DEV__)` guards.",
|
|
448
|
+
severity: "error",
|
|
449
|
+
fixable: false
|
|
450
|
+
},
|
|
451
|
+
create(context) {
|
|
452
|
+
const filePath = context.getFilePath();
|
|
453
|
+
if (filePath.includes("/tests/") || filePath.includes("/test/") || filePath.includes("/examples/") || filePath.includes(".test.") || filePath.includes(".spec.")) return {};
|
|
454
|
+
let devGuardDepth = 0;
|
|
455
|
+
return {
|
|
456
|
+
IfStatement(node) {
|
|
457
|
+
if (node.test?.type === "Identifier" && node.test.name === "__DEV__") devGuardDepth++;
|
|
458
|
+
},
|
|
459
|
+
"IfStatement:exit"(node) {
|
|
460
|
+
if (node.test?.type === "Identifier" && node.test.name === "__DEV__") devGuardDepth--;
|
|
461
|
+
},
|
|
462
|
+
CallExpression(node) {
|
|
463
|
+
if (devGuardDepth > 0) return;
|
|
464
|
+
const callee = node.callee;
|
|
465
|
+
if (callee?.type === "MemberExpression" && callee.object?.type === "Identifier" && callee.object.name === "console" && callee.property?.type === "Identifier" && (callee.property.name === "warn" || callee.property.name === "error")) context.report({
|
|
466
|
+
message: `\`console.${callee.property.name}()\` without \`__DEV__\` guard — dev warnings must be tree-shakeable in production. Wrap in \`if (__DEV__) { ... }\`.`,
|
|
467
|
+
span: getSpan(node)
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
//#endregion
|
|
475
|
+
//#region src/rules/architecture/no-circular-import.ts
|
|
476
|
+
const LAYER_ORDER = {
|
|
477
|
+
"@pyreon/reactivity": 0,
|
|
478
|
+
"@pyreon/core": 1,
|
|
479
|
+
"@pyreon/compiler": 1,
|
|
480
|
+
"@pyreon/runtime-dom": 2,
|
|
481
|
+
"@pyreon/runtime-server": 2,
|
|
482
|
+
"@pyreon/router": 3,
|
|
483
|
+
"@pyreon/head": 4,
|
|
484
|
+
"@pyreon/server": 5
|
|
485
|
+
};
|
|
486
|
+
function getLayer(source) {
|
|
487
|
+
return LAYER_ORDER[source] ?? null;
|
|
488
|
+
}
|
|
489
|
+
function getFileLayer(filePath) {
|
|
490
|
+
for (const [pkg, layer] of Object.entries(LAYER_ORDER)) {
|
|
491
|
+
const pkgName = pkg.replace("@pyreon/", "");
|
|
492
|
+
if (filePath.includes(`/packages/core/${pkgName}/`)) return layer;
|
|
493
|
+
}
|
|
494
|
+
return null;
|
|
495
|
+
}
|
|
496
|
+
const noCircularImport = {
|
|
497
|
+
meta: {
|
|
498
|
+
id: "pyreon/no-circular-import",
|
|
499
|
+
category: "architecture",
|
|
500
|
+
description: "Enforce package layer order to prevent circular imports between core packages.",
|
|
501
|
+
severity: "error",
|
|
502
|
+
fixable: false
|
|
503
|
+
},
|
|
504
|
+
create(context) {
|
|
505
|
+
const fileLayer = getFileLayer(context.getFilePath());
|
|
506
|
+
if (fileLayer === null) return {};
|
|
507
|
+
return { ImportDeclaration(node) {
|
|
508
|
+
const source = node.source?.value;
|
|
509
|
+
if (!source || !isPyreonImport(source)) return;
|
|
510
|
+
const importLayer = getLayer(source);
|
|
511
|
+
if (importLayer === null) return;
|
|
512
|
+
if (importLayer >= fileLayer) context.report({
|
|
513
|
+
message: `Importing \`${source}\` (layer ${importLayer}) from layer ${fileLayer} — this violates the package layer order and may cause circular imports.`,
|
|
514
|
+
span: getSpan(node)
|
|
515
|
+
});
|
|
516
|
+
} };
|
|
517
|
+
}
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
//#endregion
|
|
521
|
+
//#region src/rules/architecture/no-cross-layer-import.ts
|
|
522
|
+
const CORE_PACKAGES = new Set([
|
|
523
|
+
"@pyreon/reactivity",
|
|
524
|
+
"@pyreon/core",
|
|
525
|
+
"@pyreon/compiler",
|
|
526
|
+
"@pyreon/runtime-dom",
|
|
527
|
+
"@pyreon/runtime-server",
|
|
528
|
+
"@pyreon/router",
|
|
529
|
+
"@pyreon/head",
|
|
530
|
+
"@pyreon/server"
|
|
531
|
+
]);
|
|
532
|
+
const UI_PACKAGES = new Set([
|
|
533
|
+
"@pyreon/ui-core",
|
|
534
|
+
"@pyreon/styler",
|
|
535
|
+
"@pyreon/unistyle",
|
|
536
|
+
"@pyreon/elements",
|
|
537
|
+
"@pyreon/attrs",
|
|
538
|
+
"@pyreon/rocketstyle",
|
|
539
|
+
"@pyreon/coolgrid",
|
|
540
|
+
"@pyreon/kinetic",
|
|
541
|
+
"@pyreon/kinetic-presets",
|
|
542
|
+
"@pyreon/connector-document",
|
|
543
|
+
"@pyreon/document-primitives"
|
|
544
|
+
]);
|
|
545
|
+
function getImportCategory(source) {
|
|
546
|
+
if (CORE_PACKAGES.has(source)) return "core";
|
|
547
|
+
if (UI_PACKAGES.has(source)) return "ui-system";
|
|
548
|
+
return null;
|
|
549
|
+
}
|
|
550
|
+
function getFileCategory(filePath) {
|
|
551
|
+
if (filePath.includes("/packages/core/")) return "core";
|
|
552
|
+
if (filePath.includes("/packages/ui-system/")) return "ui-system";
|
|
553
|
+
if (filePath.includes("/packages/fundamentals/")) return "fundamentals";
|
|
554
|
+
if (filePath.includes("/packages/tools/")) return "tools";
|
|
555
|
+
return null;
|
|
556
|
+
}
|
|
557
|
+
const noCrossLayerImport = {
|
|
558
|
+
meta: {
|
|
559
|
+
id: "pyreon/no-cross-layer-import",
|
|
560
|
+
category: "architecture",
|
|
561
|
+
description: "Prevent core packages from importing ui-system packages.",
|
|
562
|
+
severity: "error",
|
|
563
|
+
fixable: false
|
|
564
|
+
},
|
|
565
|
+
create(context) {
|
|
566
|
+
if (getFileCategory(context.getFilePath()) !== "core") return {};
|
|
567
|
+
return { ImportDeclaration(node) {
|
|
568
|
+
const source = node.source?.value;
|
|
569
|
+
if (!source || !isPyreonImport(source)) return;
|
|
570
|
+
if (getImportCategory(source) === "ui-system") context.report({
|
|
571
|
+
message: `Core package importing ui-system package \`${source}\` — core packages must not depend on ui-system.`,
|
|
572
|
+
span: getSpan(node)
|
|
573
|
+
});
|
|
574
|
+
} };
|
|
575
|
+
}
|
|
576
|
+
};
|
|
577
|
+
|
|
578
|
+
//#endregion
|
|
579
|
+
//#region src/rules/architecture/no-deep-import.ts
|
|
580
|
+
const DEEP_IMPORT_PATTERN = /@pyreon\/[^/]+\/(src|dist|lib)\//;
|
|
581
|
+
const noDeepImport = {
|
|
582
|
+
meta: {
|
|
583
|
+
id: "pyreon/no-deep-import",
|
|
584
|
+
category: "architecture",
|
|
585
|
+
description: "Disallow importing from @pyreon/*/src/, /dist/, or /lib/ — use public exports instead.",
|
|
586
|
+
severity: "warn",
|
|
587
|
+
fixable: false
|
|
588
|
+
},
|
|
589
|
+
create(context) {
|
|
590
|
+
return { ImportDeclaration(node) {
|
|
591
|
+
const source = node.source?.value;
|
|
592
|
+
if (!source || !isPyreonImport(source)) return;
|
|
593
|
+
if (DEEP_IMPORT_PATTERN.test(source)) context.report({
|
|
594
|
+
message: `Deep import \`${source}\` — import from the package's public exports instead.`,
|
|
595
|
+
span: getSpan(node)
|
|
596
|
+
});
|
|
597
|
+
} };
|
|
598
|
+
}
|
|
599
|
+
};
|
|
600
|
+
|
|
601
|
+
//#endregion
|
|
602
|
+
//#region src/rules/architecture/no-error-without-prefix.ts
|
|
603
|
+
const noErrorWithoutPrefix = {
|
|
604
|
+
meta: {
|
|
605
|
+
id: "pyreon/no-error-without-prefix",
|
|
606
|
+
category: "architecture",
|
|
607
|
+
description: "Require error messages to be prefixed with [Pyreon].",
|
|
608
|
+
severity: "warn",
|
|
609
|
+
fixable: true
|
|
610
|
+
},
|
|
611
|
+
create(context) {
|
|
612
|
+
const filePath = context.getFilePath();
|
|
613
|
+
if (filePath.includes("/tests/") || filePath.includes("/test/") || filePath.includes(".test.") || filePath.includes(".spec.")) return {};
|
|
614
|
+
return { ThrowStatement(node) {
|
|
615
|
+
const arg = node.argument;
|
|
616
|
+
if (!arg || arg.type !== "NewExpression") return;
|
|
617
|
+
const callee = arg.callee;
|
|
618
|
+
if (!callee || callee.type !== "Identifier" || callee.name !== "Error") return;
|
|
619
|
+
const args = arg.arguments;
|
|
620
|
+
if (!args || args.length === 0) return;
|
|
621
|
+
const firstArg = args[0];
|
|
622
|
+
if (!firstArg) return;
|
|
623
|
+
if (firstArg.type === "Literal" || firstArg.type === "StringLiteral") {
|
|
624
|
+
const value = firstArg.value;
|
|
625
|
+
if (typeof value === "string" && !value.startsWith("[Pyreon]")) {
|
|
626
|
+
const argSpan = getSpan(firstArg);
|
|
627
|
+
const quote = context.getSourceText()[argSpan.start];
|
|
628
|
+
const fixedValue = `${quote}[Pyreon] ${value}${quote}`;
|
|
629
|
+
context.report({
|
|
630
|
+
message: "Error message missing `[Pyreon]` prefix — all framework errors should be prefixed for identification.",
|
|
631
|
+
span: getSpan(node),
|
|
632
|
+
fix: {
|
|
633
|
+
span: argSpan,
|
|
634
|
+
replacement: fixedValue
|
|
635
|
+
}
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
if (firstArg.type === "TemplateLiteral") {
|
|
640
|
+
const quasis = firstArg.quasis;
|
|
641
|
+
if (quasis && quasis.length > 0) {
|
|
642
|
+
const first = quasis[0];
|
|
643
|
+
if (!(first.value?.raw ?? first.value?.cooked ?? "").startsWith("[Pyreon]")) {
|
|
644
|
+
const argSpan = getSpan(firstArg);
|
|
645
|
+
const fixed = context.getSourceText().slice(argSpan.start, argSpan.end).replace(/^`/, "`[Pyreon] ");
|
|
646
|
+
context.report({
|
|
647
|
+
message: "Error message missing `[Pyreon]` prefix — all framework errors should be prefixed for identification.",
|
|
648
|
+
span: getSpan(node),
|
|
649
|
+
fix: {
|
|
650
|
+
span: argSpan,
|
|
651
|
+
replacement: fixed
|
|
652
|
+
}
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
} };
|
|
658
|
+
}
|
|
659
|
+
};
|
|
660
|
+
|
|
661
|
+
//#endregion
|
|
662
|
+
//#region src/rules/form/no-submit-without-validation.ts
|
|
663
|
+
const noSubmitWithoutValidation = {
|
|
664
|
+
meta: {
|
|
665
|
+
id: "pyreon/no-submit-without-validation",
|
|
666
|
+
category: "form",
|
|
667
|
+
description: "Warn when useForm() has onSubmit but no validators or schema.",
|
|
668
|
+
severity: "warn",
|
|
669
|
+
fixable: false
|
|
670
|
+
},
|
|
671
|
+
create(context) {
|
|
672
|
+
return { CallExpression(node) {
|
|
673
|
+
if (!isCallTo(node, "useForm")) return;
|
|
674
|
+
const args = node.arguments;
|
|
675
|
+
if (!args || args.length === 0) return;
|
|
676
|
+
const options = args[0];
|
|
677
|
+
if (!options || options.type !== "ObjectExpression") return;
|
|
678
|
+
let hasOnSubmit = false;
|
|
679
|
+
let hasValidation = false;
|
|
680
|
+
for (const prop of options.properties ?? []) {
|
|
681
|
+
if (prop.type !== "Property") continue;
|
|
682
|
+
const key = prop.key;
|
|
683
|
+
if (!key) continue;
|
|
684
|
+
const name = key.type === "Identifier" ? key.name : null;
|
|
685
|
+
if (name === "onSubmit") hasOnSubmit = true;
|
|
686
|
+
if (name === "validators" || name === "schema") hasValidation = true;
|
|
687
|
+
}
|
|
688
|
+
if (hasOnSubmit && !hasValidation) context.report({
|
|
689
|
+
message: "`useForm()` has `onSubmit` without `validators` or `schema` — consider adding validation for data integrity.",
|
|
690
|
+
span: getSpan(node)
|
|
691
|
+
});
|
|
692
|
+
} };
|
|
693
|
+
}
|
|
694
|
+
};
|
|
695
|
+
|
|
696
|
+
//#endregion
|
|
697
|
+
//#region src/rules/form/no-unregistered-field.ts
|
|
698
|
+
const noUnregisteredField = {
|
|
699
|
+
meta: {
|
|
700
|
+
id: "pyreon/no-unregistered-field",
|
|
701
|
+
category: "form",
|
|
702
|
+
description: "Warn when useField() is called without a corresponding register() call.",
|
|
703
|
+
severity: "warn",
|
|
704
|
+
fixable: false
|
|
705
|
+
},
|
|
706
|
+
create(context) {
|
|
707
|
+
const fieldDecls = /* @__PURE__ */ new Map();
|
|
708
|
+
const registeredNames = /* @__PURE__ */ new Set();
|
|
709
|
+
return {
|
|
710
|
+
VariableDeclarator(node) {
|
|
711
|
+
const init = node.init;
|
|
712
|
+
if (!init || !isCallTo(init, "useField")) return;
|
|
713
|
+
const id = node.id;
|
|
714
|
+
if (!id || id.type !== "Identifier") return;
|
|
715
|
+
fieldDecls.set(id.name, { span: getSpan(node) });
|
|
716
|
+
},
|
|
717
|
+
CallExpression(node) {
|
|
718
|
+
const callee = node.callee;
|
|
719
|
+
if (!callee || callee.type !== "MemberExpression") return;
|
|
720
|
+
if (callee.property?.type !== "Identifier" || callee.property.name !== "register") return;
|
|
721
|
+
if (callee.object?.type === "Identifier") registeredNames.add(callee.object.name);
|
|
722
|
+
},
|
|
723
|
+
"Program:exit"() {
|
|
724
|
+
for (const [name, { span }] of fieldDecls) if (!registeredNames.has(name)) context.report({
|
|
725
|
+
message: `\`useField()\` result \`${name}\` is never registered — call \`${name}.register()\` to connect it to the form.`,
|
|
726
|
+
span
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
};
|
|
732
|
+
|
|
733
|
+
//#endregion
|
|
734
|
+
//#region src/rules/form/prefer-field-array.ts
|
|
735
|
+
const preferFieldArray = {
|
|
736
|
+
meta: {
|
|
737
|
+
id: "pyreon/prefer-field-array",
|
|
738
|
+
category: "form",
|
|
739
|
+
description: "Suggest useFieldArray() instead of signal([]) in files that import @pyreon/form.",
|
|
740
|
+
severity: "info",
|
|
741
|
+
fixable: false
|
|
742
|
+
},
|
|
743
|
+
create(context) {
|
|
744
|
+
let importsForm = false;
|
|
745
|
+
return {
|
|
746
|
+
ImportDeclaration(node) {
|
|
747
|
+
const info = extractImportInfo(node);
|
|
748
|
+
if (info && info.source === "@pyreon/form") importsForm = true;
|
|
749
|
+
},
|
|
750
|
+
CallExpression(node) {
|
|
751
|
+
if (!importsForm) return;
|
|
752
|
+
if (!isCallTo(node, "signal")) return;
|
|
753
|
+
const args = node.arguments;
|
|
754
|
+
if (!args || args.length === 0) return;
|
|
755
|
+
if (args[0]?.type === "ArrayExpression") context.report({
|
|
756
|
+
message: "`signal([])` in a form file — consider using `useFieldArray()` for dynamic array fields with stable keys.",
|
|
757
|
+
span: getSpan(node)
|
|
758
|
+
});
|
|
759
|
+
}
|
|
760
|
+
};
|
|
761
|
+
}
|
|
762
|
+
};
|
|
763
|
+
|
|
764
|
+
//#endregion
|
|
765
|
+
//#region src/rules/hooks/no-raw-addeventlistener.ts
|
|
766
|
+
const noRawAddEventListener = {
|
|
767
|
+
meta: {
|
|
768
|
+
id: "pyreon/no-raw-addeventlistener",
|
|
769
|
+
category: "hooks",
|
|
770
|
+
description: "Suggest useEventListener() instead of raw .addEventListener() calls.",
|
|
771
|
+
severity: "info",
|
|
772
|
+
fixable: false
|
|
773
|
+
},
|
|
774
|
+
create(context) {
|
|
775
|
+
return { CallExpression(node) {
|
|
776
|
+
const callee = node.callee;
|
|
777
|
+
if (!callee || callee.type !== "MemberExpression") return;
|
|
778
|
+
if (callee.property?.type !== "Identifier" || callee.property.name !== "addEventListener") return;
|
|
779
|
+
context.report({
|
|
780
|
+
message: "Raw `.addEventListener()` — consider using `useEventListener()` from `@pyreon/hooks` for auto-cleanup on unmount.",
|
|
781
|
+
span: getSpan(node)
|
|
782
|
+
});
|
|
783
|
+
} };
|
|
784
|
+
}
|
|
785
|
+
};
|
|
786
|
+
|
|
787
|
+
//#endregion
|
|
788
|
+
//#region src/rules/hooks/no-raw-localstorage.ts
|
|
789
|
+
const STORAGE_OBJECTS = new Set(["localStorage", "sessionStorage"]);
|
|
790
|
+
const STORAGE_METHODS = new Set([
|
|
791
|
+
"getItem",
|
|
792
|
+
"setItem",
|
|
793
|
+
"removeItem"
|
|
794
|
+
]);
|
|
795
|
+
const noRawLocalStorage = {
|
|
796
|
+
meta: {
|
|
797
|
+
id: "pyreon/no-raw-localstorage",
|
|
798
|
+
category: "hooks",
|
|
799
|
+
description: "Suggest useStorage() instead of raw localStorage/sessionStorage access.",
|
|
800
|
+
severity: "info",
|
|
801
|
+
fixable: false
|
|
802
|
+
},
|
|
803
|
+
create(context) {
|
|
804
|
+
return { CallExpression(node) {
|
|
805
|
+
const callee = node.callee;
|
|
806
|
+
if (!callee || callee.type !== "MemberExpression") return;
|
|
807
|
+
if (callee.object?.type === "Identifier" && STORAGE_OBJECTS.has(callee.object.name) && callee.property?.type === "Identifier" && STORAGE_METHODS.has(callee.property.name)) context.report({
|
|
808
|
+
message: `Raw \`${callee.object.name}.${callee.property.name}()\` — consider using \`useStorage()\` from \`@pyreon/storage\` for reactive, cross-tab synced storage.`,
|
|
809
|
+
span: getSpan(node)
|
|
810
|
+
});
|
|
811
|
+
} };
|
|
812
|
+
}
|
|
813
|
+
};
|
|
814
|
+
|
|
815
|
+
//#endregion
|
|
816
|
+
//#region src/rules/hooks/no-raw-setinterval.ts
|
|
817
|
+
const TIMER_FNS = new Set(["setInterval", "setTimeout"]);
|
|
818
|
+
const noRawSetInterval = {
|
|
819
|
+
meta: {
|
|
820
|
+
id: "pyreon/no-raw-setinterval",
|
|
821
|
+
category: "hooks",
|
|
822
|
+
description: "Suggest wrapping setInterval/setTimeout in onMount for automatic cleanup.",
|
|
823
|
+
severity: "info",
|
|
824
|
+
fixable: false
|
|
825
|
+
},
|
|
826
|
+
create(context) {
|
|
827
|
+
let mountDepth = 0;
|
|
828
|
+
return {
|
|
829
|
+
CallExpression(node) {
|
|
830
|
+
if (isCallTo(node, "onMount")) mountDepth++;
|
|
831
|
+
if (mountDepth > 0) return;
|
|
832
|
+
const callee = node.callee;
|
|
833
|
+
if (!callee || callee.type !== "Identifier") return;
|
|
834
|
+
if (TIMER_FNS.has(callee.name)) context.report({
|
|
835
|
+
message: `\`${callee.name}()\` outside \`onMount\` — wrap in \`onMount(() => { ... return () => clear... })\` for automatic cleanup.`,
|
|
836
|
+
span: getSpan(node)
|
|
837
|
+
});
|
|
838
|
+
},
|
|
839
|
+
"CallExpression:exit"(node) {
|
|
840
|
+
if (isCallTo(node, "onMount")) mountDepth--;
|
|
841
|
+
}
|
|
842
|
+
};
|
|
843
|
+
}
|
|
844
|
+
};
|
|
845
|
+
|
|
846
|
+
//#endregion
|
|
847
|
+
//#region src/rules/jsx/no-and-conditional.ts
|
|
848
|
+
const noAndConditional = {
|
|
849
|
+
meta: {
|
|
850
|
+
id: "pyreon/no-and-conditional",
|
|
851
|
+
category: "jsx",
|
|
852
|
+
description: "Prefer <Show> over `&&` with JSX in expression containers.",
|
|
853
|
+
severity: "warn",
|
|
854
|
+
fixable: false
|
|
855
|
+
},
|
|
856
|
+
create(context) {
|
|
857
|
+
let jsxExpressionDepth = 0;
|
|
858
|
+
return {
|
|
859
|
+
JSXExpressionContainer() {
|
|
860
|
+
jsxExpressionDepth++;
|
|
861
|
+
},
|
|
862
|
+
"JSXExpressionContainer:exit"() {
|
|
863
|
+
jsxExpressionDepth--;
|
|
864
|
+
},
|
|
865
|
+
LogicalExpression(node) {
|
|
866
|
+
if (jsxExpressionDepth === 0) return;
|
|
867
|
+
if (!isLogicalAndWithJSX(node)) return;
|
|
868
|
+
context.report({
|
|
869
|
+
message: "`&&` with JSX — use `<Show>` for conditional rendering.",
|
|
870
|
+
span: getSpan(node)
|
|
871
|
+
});
|
|
872
|
+
}
|
|
873
|
+
};
|
|
874
|
+
}
|
|
875
|
+
};
|
|
876
|
+
|
|
877
|
+
//#endregion
|
|
878
|
+
//#region src/rules/jsx/no-children-access.ts
|
|
879
|
+
const noChildrenAccess = {
|
|
880
|
+
meta: {
|
|
881
|
+
id: "pyreon/no-children-access",
|
|
882
|
+
category: "jsx",
|
|
883
|
+
description: "Inform about direct props.children access in renderer files.",
|
|
884
|
+
severity: "info",
|
|
885
|
+
fixable: false
|
|
886
|
+
},
|
|
887
|
+
create(context) {
|
|
888
|
+
const imports = [];
|
|
889
|
+
let isRendererFile = false;
|
|
890
|
+
return {
|
|
891
|
+
ImportDeclaration(node) {
|
|
892
|
+
const info = extractImportInfo(node);
|
|
893
|
+
if (info) {
|
|
894
|
+
imports.push(info);
|
|
895
|
+
if (info.source === "@pyreon/runtime-server" || info.source === "@pyreon/runtime-dom") isRendererFile = true;
|
|
896
|
+
}
|
|
897
|
+
},
|
|
898
|
+
MemberExpression(node) {
|
|
899
|
+
if (!isRendererFile) return;
|
|
900
|
+
if (node.object?.type === "Identifier" && node.property?.type === "Identifier" && node.property.name === "children") context.report({
|
|
901
|
+
message: "Direct `props.children` access in a renderer file — children are already merged via `mergeChildrenIntoProps`.",
|
|
902
|
+
span: getSpan(node)
|
|
903
|
+
});
|
|
904
|
+
}
|
|
905
|
+
};
|
|
906
|
+
}
|
|
907
|
+
};
|
|
908
|
+
|
|
909
|
+
//#endregion
|
|
910
|
+
//#region src/rules/jsx/no-classname.ts
|
|
911
|
+
const noClassName = {
|
|
912
|
+
meta: {
|
|
913
|
+
id: "pyreon/no-classname",
|
|
914
|
+
category: "jsx",
|
|
915
|
+
description: "Use `class` instead of `className` — Pyreon uses standard HTML attributes.",
|
|
916
|
+
severity: "error",
|
|
917
|
+
fixable: true
|
|
918
|
+
},
|
|
919
|
+
create(context) {
|
|
920
|
+
return { JSXAttribute(node) {
|
|
921
|
+
if (node.name?.type !== "JSXIdentifier") return;
|
|
922
|
+
if (node.name.name !== "className") return;
|
|
923
|
+
const nameSpan = getSpan(node.name);
|
|
924
|
+
context.report({
|
|
925
|
+
message: "Use `class` instead of `className` — Pyreon uses standard HTML attributes.",
|
|
926
|
+
span: getSpan(node),
|
|
927
|
+
fix: {
|
|
928
|
+
span: nameSpan,
|
|
929
|
+
replacement: "class"
|
|
930
|
+
}
|
|
931
|
+
});
|
|
932
|
+
} };
|
|
933
|
+
}
|
|
934
|
+
};
|
|
935
|
+
|
|
936
|
+
//#endregion
|
|
937
|
+
//#region src/rules/jsx/no-htmlfor.ts
|
|
938
|
+
const noHtmlFor = {
|
|
939
|
+
meta: {
|
|
940
|
+
id: "pyreon/no-htmlfor",
|
|
941
|
+
category: "jsx",
|
|
942
|
+
description: "Use `for` instead of `htmlFor` — Pyreon uses standard HTML attributes.",
|
|
943
|
+
severity: "error",
|
|
944
|
+
fixable: true
|
|
945
|
+
},
|
|
946
|
+
create(context) {
|
|
947
|
+
return { JSXAttribute(node) {
|
|
948
|
+
if (node.name?.type !== "JSXIdentifier") return;
|
|
949
|
+
if (node.name.name !== "htmlFor") return;
|
|
950
|
+
const nameSpan = getSpan(node.name);
|
|
951
|
+
context.report({
|
|
952
|
+
message: "Use `for` instead of `htmlFor` — Pyreon uses standard HTML attributes.",
|
|
953
|
+
span: getSpan(node),
|
|
954
|
+
fix: {
|
|
955
|
+
span: nameSpan,
|
|
956
|
+
replacement: "for"
|
|
957
|
+
}
|
|
958
|
+
});
|
|
959
|
+
} };
|
|
960
|
+
}
|
|
961
|
+
};
|
|
962
|
+
|
|
963
|
+
//#endregion
|
|
964
|
+
//#region src/rules/jsx/no-index-as-by.ts
|
|
965
|
+
const noIndexAsBy = {
|
|
966
|
+
meta: {
|
|
967
|
+
id: "pyreon/no-index-as-by",
|
|
968
|
+
category: "jsx",
|
|
969
|
+
description: "Disallow using index as `by` prop on <For> — use a unique key instead.",
|
|
970
|
+
severity: "warn",
|
|
971
|
+
fixable: false
|
|
972
|
+
},
|
|
973
|
+
create(context) {
|
|
974
|
+
return { JSXOpeningElement(node) {
|
|
975
|
+
const name = node.name;
|
|
976
|
+
if (!name || name.type !== "JSXIdentifier" || name.name !== "For") return;
|
|
977
|
+
const byAttr = getJSXAttribute(node, "by");
|
|
978
|
+
if (!byAttr) return;
|
|
979
|
+
const value = byAttr.value;
|
|
980
|
+
if (!value || value.type !== "JSXExpressionContainer") return;
|
|
981
|
+
const expr = value.expression;
|
|
982
|
+
if (!expr) return;
|
|
983
|
+
if (expr.type === "ArrowFunctionExpression" || expr.type === "FunctionExpression") {
|
|
984
|
+
const params = expr.params;
|
|
985
|
+
if (!params || params.length < 2) return;
|
|
986
|
+
const secondParam = params[1];
|
|
987
|
+
if (!secondParam || secondParam.type !== "Identifier") return;
|
|
988
|
+
const indexName = secondParam.name;
|
|
989
|
+
const body = expr.body;
|
|
990
|
+
if (body?.type === "Identifier" && body.name === indexName) context.report({
|
|
991
|
+
message: "Using index as `by` prop on `<For>` — use a unique key from the data instead.",
|
|
992
|
+
span: getSpan(byAttr)
|
|
993
|
+
});
|
|
994
|
+
if (body?.type === "BlockStatement") {
|
|
995
|
+
const stmts = body.body;
|
|
996
|
+
if (stmts?.length === 1) {
|
|
997
|
+
const stmt = stmts[0];
|
|
998
|
+
if (stmt.type === "ReturnStatement" && stmt.argument?.type === "Identifier" && stmt.argument.name === indexName) context.report({
|
|
999
|
+
message: "Using index as `by` prop on `<For>` — use a unique key from the data instead.",
|
|
1000
|
+
span: getSpan(byAttr)
|
|
1001
|
+
});
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
} };
|
|
1006
|
+
}
|
|
1007
|
+
};
|
|
1008
|
+
|
|
1009
|
+
//#endregion
|
|
1010
|
+
//#region src/rules/jsx/no-map-in-jsx.ts
|
|
1011
|
+
const noMapInJsx = {
|
|
1012
|
+
meta: {
|
|
1013
|
+
id: "pyreon/no-map-in-jsx",
|
|
1014
|
+
category: "jsx",
|
|
1015
|
+
description: "Prefer <For> over .map() inside JSX for reactive list rendering.",
|
|
1016
|
+
severity: "warn",
|
|
1017
|
+
fixable: false
|
|
1018
|
+
},
|
|
1019
|
+
create(context) {
|
|
1020
|
+
let jsxDepth = 0;
|
|
1021
|
+
return {
|
|
1022
|
+
JSXElement() {
|
|
1023
|
+
jsxDepth++;
|
|
1024
|
+
},
|
|
1025
|
+
"JSXElement:exit"() {
|
|
1026
|
+
jsxDepth--;
|
|
1027
|
+
},
|
|
1028
|
+
JSXFragment() {
|
|
1029
|
+
jsxDepth++;
|
|
1030
|
+
},
|
|
1031
|
+
"JSXFragment:exit"() {
|
|
1032
|
+
jsxDepth--;
|
|
1033
|
+
},
|
|
1034
|
+
CallExpression(node) {
|
|
1035
|
+
if (jsxDepth === 0) return;
|
|
1036
|
+
if (!isArrayMapCall(node)) return;
|
|
1037
|
+
const args = node.arguments;
|
|
1038
|
+
if (!args || args.length === 0) return;
|
|
1039
|
+
if (!args[0]) return;
|
|
1040
|
+
context.report({
|
|
1041
|
+
message: "`.map()` in JSX — use `<For>` for reactive list rendering instead.",
|
|
1042
|
+
span: getSpan(node)
|
|
1043
|
+
});
|
|
1044
|
+
}
|
|
1045
|
+
};
|
|
1046
|
+
}
|
|
1047
|
+
};
|
|
1048
|
+
|
|
1049
|
+
//#endregion
|
|
1050
|
+
//#region src/rules/jsx/no-missing-for-by.ts
|
|
1051
|
+
const noMissingForBy = {
|
|
1052
|
+
meta: {
|
|
1053
|
+
id: "pyreon/no-missing-for-by",
|
|
1054
|
+
category: "jsx",
|
|
1055
|
+
description: "Warn when <For> is used without a `by` prop.",
|
|
1056
|
+
severity: "warn",
|
|
1057
|
+
fixable: false
|
|
1058
|
+
},
|
|
1059
|
+
create(context) {
|
|
1060
|
+
return { JSXOpeningElement(node) {
|
|
1061
|
+
const name = node.name;
|
|
1062
|
+
if (!name || name.type !== "JSXIdentifier" || name.name !== "For") return;
|
|
1063
|
+
if (hasJSXAttribute(node, "by")) return;
|
|
1064
|
+
context.report({
|
|
1065
|
+
message: "`<For>` without `by` prop — provide a key function for efficient reconciliation.",
|
|
1066
|
+
span: getSpan(node)
|
|
1067
|
+
});
|
|
1068
|
+
} };
|
|
1069
|
+
}
|
|
1070
|
+
};
|
|
1071
|
+
|
|
1072
|
+
//#endregion
|
|
1073
|
+
//#region src/rules/jsx/no-onchange.ts
|
|
1074
|
+
const INPUT_TAGS = new Set([
|
|
1075
|
+
"input",
|
|
1076
|
+
"textarea",
|
|
1077
|
+
"select"
|
|
1078
|
+
]);
|
|
1079
|
+
const noOnChange = {
|
|
1080
|
+
meta: {
|
|
1081
|
+
id: "pyreon/no-onchange",
|
|
1082
|
+
category: "jsx",
|
|
1083
|
+
description: "Prefer `onInput` over `onChange` on input elements for keypress-by-keypress updates.",
|
|
1084
|
+
severity: "warn",
|
|
1085
|
+
fixable: true
|
|
1086
|
+
},
|
|
1087
|
+
create(context) {
|
|
1088
|
+
let currentTag = null;
|
|
1089
|
+
return { JSXOpeningElement(node) {
|
|
1090
|
+
const name = node.name;
|
|
1091
|
+
if (name?.type === "JSXIdentifier" && INPUT_TAGS.has(name.name)) currentTag = name.name;
|
|
1092
|
+
else currentTag = null;
|
|
1093
|
+
if (!currentTag) return;
|
|
1094
|
+
const attrs = node.attributes ?? [];
|
|
1095
|
+
for (const attr of attrs) if (attr.type === "JSXAttribute" && attr.name?.type === "JSXIdentifier" && attr.name.name === "onChange") {
|
|
1096
|
+
const nameSpan = getSpan(attr.name);
|
|
1097
|
+
context.report({
|
|
1098
|
+
message: `Use \`onInput\` instead of \`onChange\` on \`<${currentTag}>\` for keypress-by-keypress updates.`,
|
|
1099
|
+
span: getSpan(attr),
|
|
1100
|
+
fix: {
|
|
1101
|
+
span: nameSpan,
|
|
1102
|
+
replacement: "onInput"
|
|
1103
|
+
}
|
|
1104
|
+
});
|
|
1105
|
+
}
|
|
1106
|
+
} };
|
|
1107
|
+
}
|
|
1108
|
+
};
|
|
1109
|
+
|
|
1110
|
+
//#endregion
|
|
1111
|
+
//#region src/rules/jsx/no-props-destructure.ts
|
|
1112
|
+
function containsJSXReturn(node) {
|
|
1113
|
+
if (!node) return false;
|
|
1114
|
+
if (node.type === "JSXElement" || node.type === "JSXFragment") return true;
|
|
1115
|
+
if (node.type === "ParenthesizedExpression") return containsJSXReturn(node.expression);
|
|
1116
|
+
if (node.type === "BlockStatement") {
|
|
1117
|
+
for (const stmt of node.body ?? []) if (stmt.type === "ReturnStatement" && containsJSXReturn(stmt.argument)) return true;
|
|
1118
|
+
}
|
|
1119
|
+
return false;
|
|
1120
|
+
}
|
|
1121
|
+
const noPropsDestructure = {
|
|
1122
|
+
meta: {
|
|
1123
|
+
id: "pyreon/no-props-destructure",
|
|
1124
|
+
category: "jsx",
|
|
1125
|
+
description: "Disallow destructuring props in component functions — it breaks signal reactivity.",
|
|
1126
|
+
severity: "error",
|
|
1127
|
+
fixable: false
|
|
1128
|
+
},
|
|
1129
|
+
create(context) {
|
|
1130
|
+
return {
|
|
1131
|
+
ArrowFunctionExpression(node) {
|
|
1132
|
+
checkFunction(node, context);
|
|
1133
|
+
},
|
|
1134
|
+
FunctionDeclaration(node) {
|
|
1135
|
+
checkFunction(node, context);
|
|
1136
|
+
},
|
|
1137
|
+
FunctionExpression(node) {
|
|
1138
|
+
checkFunction(node, context);
|
|
1139
|
+
}
|
|
1140
|
+
};
|
|
1141
|
+
}
|
|
1142
|
+
};
|
|
1143
|
+
function checkFunction(node, context) {
|
|
1144
|
+
const params = node.params;
|
|
1145
|
+
if (!params || params.length === 0) return;
|
|
1146
|
+
const firstParam = params[0];
|
|
1147
|
+
if (!isDestructuring(firstParam)) return;
|
|
1148
|
+
const body = node.body;
|
|
1149
|
+
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
|
+
});
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
//#endregion
|
|
1157
|
+
//#region src/rules/jsx/no-ternary-conditional.ts
|
|
1158
|
+
const noTernaryConditional = {
|
|
1159
|
+
meta: {
|
|
1160
|
+
id: "pyreon/no-ternary-conditional",
|
|
1161
|
+
category: "jsx",
|
|
1162
|
+
description: "Prefer <Show> over ternary expressions with JSX branches.",
|
|
1163
|
+
severity: "warn",
|
|
1164
|
+
fixable: false
|
|
1165
|
+
},
|
|
1166
|
+
create(context) {
|
|
1167
|
+
let jsxExpressionDepth = 0;
|
|
1168
|
+
return {
|
|
1169
|
+
JSXExpressionContainer() {
|
|
1170
|
+
jsxExpressionDepth++;
|
|
1171
|
+
},
|
|
1172
|
+
"JSXExpressionContainer:exit"() {
|
|
1173
|
+
jsxExpressionDepth--;
|
|
1174
|
+
},
|
|
1175
|
+
ConditionalExpression(node) {
|
|
1176
|
+
if (jsxExpressionDepth === 0) return;
|
|
1177
|
+
if (!isTernaryWithJSX(node)) return;
|
|
1178
|
+
context.report({
|
|
1179
|
+
message: "Ternary with JSX — use `<Show>` for more efficient conditional rendering.",
|
|
1180
|
+
span: getSpan(node)
|
|
1181
|
+
});
|
|
1182
|
+
}
|
|
1183
|
+
};
|
|
1184
|
+
}
|
|
1185
|
+
};
|
|
1186
|
+
|
|
1187
|
+
//#endregion
|
|
1188
|
+
//#region src/rules/jsx/use-by-not-key.ts
|
|
1189
|
+
const useByNotKey = {
|
|
1190
|
+
meta: {
|
|
1191
|
+
id: "pyreon/use-by-not-key",
|
|
1192
|
+
category: "jsx",
|
|
1193
|
+
description: "Use `by` prop on <For> instead of `key` — JSX reserves `key` for VNode reconciliation.",
|
|
1194
|
+
severity: "error",
|
|
1195
|
+
fixable: true
|
|
1196
|
+
},
|
|
1197
|
+
create(context) {
|
|
1198
|
+
return { JSXOpeningElement(node) {
|
|
1199
|
+
if ((node.name?.type === "JSXIdentifier" ? node.name.name : null) !== "For") return;
|
|
1200
|
+
const keyAttr = getJSXAttribute(node, "key");
|
|
1201
|
+
if (!keyAttr) return;
|
|
1202
|
+
if (hasJSXAttribute(node, "by")) return;
|
|
1203
|
+
const attrSpan = getSpan(keyAttr.name);
|
|
1204
|
+
context.report({
|
|
1205
|
+
message: "Use `by` prop on `<For>` instead of `key` — JSX reserves `key` for VNode reconciliation.",
|
|
1206
|
+
span: getSpan(keyAttr),
|
|
1207
|
+
fix: {
|
|
1208
|
+
span: attrSpan,
|
|
1209
|
+
replacement: "by"
|
|
1210
|
+
}
|
|
1211
|
+
});
|
|
1212
|
+
} };
|
|
1213
|
+
}
|
|
1214
|
+
};
|
|
1215
|
+
|
|
1216
|
+
//#endregion
|
|
1217
|
+
//#region src/rules/lifecycle/no-dom-in-setup.ts
|
|
1218
|
+
const DOM_METHODS = new Set([
|
|
1219
|
+
"querySelector",
|
|
1220
|
+
"querySelectorAll",
|
|
1221
|
+
"getElementById",
|
|
1222
|
+
"getElementsByClassName",
|
|
1223
|
+
"getElementsByTagName"
|
|
1224
|
+
]);
|
|
1225
|
+
const noDomInSetup = {
|
|
1226
|
+
meta: {
|
|
1227
|
+
id: "pyreon/no-dom-in-setup",
|
|
1228
|
+
category: "lifecycle",
|
|
1229
|
+
description: "Warn when DOM query methods are used outside onMount or effect.",
|
|
1230
|
+
severity: "warn",
|
|
1231
|
+
fixable: false
|
|
1232
|
+
},
|
|
1233
|
+
create(context) {
|
|
1234
|
+
let safeDepth = 0;
|
|
1235
|
+
return {
|
|
1236
|
+
CallExpression(node) {
|
|
1237
|
+
if (isCallTo(node, "onMount") || isCallTo(node, "effect")) safeDepth++;
|
|
1238
|
+
if (safeDepth > 0) return;
|
|
1239
|
+
const callee = node.callee;
|
|
1240
|
+
if (callee?.type === "MemberExpression" && callee.object?.type === "Identifier" && callee.object.name === "document" && callee.property?.type === "Identifier" && DOM_METHODS.has(callee.property.name)) context.report({
|
|
1241
|
+
message: `\`document.${callee.property.name}()\` outside \`onMount\`/\`effect\` — DOM is not available during SSR or setup phase.`,
|
|
1242
|
+
span: getSpan(node)
|
|
1243
|
+
});
|
|
1244
|
+
},
|
|
1245
|
+
"CallExpression:exit"(node) {
|
|
1246
|
+
if (isCallTo(node, "onMount") || isCallTo(node, "effect")) safeDepth--;
|
|
1247
|
+
}
|
|
1248
|
+
};
|
|
1249
|
+
}
|
|
1250
|
+
};
|
|
1251
|
+
|
|
1252
|
+
//#endregion
|
|
1253
|
+
//#region src/rules/lifecycle/no-effect-in-mount.ts
|
|
1254
|
+
const noEffectInMount = {
|
|
1255
|
+
meta: {
|
|
1256
|
+
id: "pyreon/no-effect-in-mount",
|
|
1257
|
+
category: "lifecycle",
|
|
1258
|
+
description: "Inform when effect() is created inside onMount — effects are typically created at setup time.",
|
|
1259
|
+
severity: "info",
|
|
1260
|
+
fixable: false
|
|
1261
|
+
},
|
|
1262
|
+
create(context) {
|
|
1263
|
+
let mountDepth = 0;
|
|
1264
|
+
return {
|
|
1265
|
+
CallExpression(node) {
|
|
1266
|
+
if (isCallTo(node, "onMount")) mountDepth++;
|
|
1267
|
+
if (mountDepth > 0 && isCallTo(node, "effect")) context.report({
|
|
1268
|
+
message: "`effect()` inside `onMount` — effects are typically created at component setup time, not inside lifecycle hooks.",
|
|
1269
|
+
span: getSpan(node)
|
|
1270
|
+
});
|
|
1271
|
+
},
|
|
1272
|
+
"CallExpression:exit"(node) {
|
|
1273
|
+
if (isCallTo(node, "onMount")) mountDepth--;
|
|
1274
|
+
}
|
|
1275
|
+
};
|
|
1276
|
+
}
|
|
1277
|
+
};
|
|
1278
|
+
|
|
1279
|
+
//#endregion
|
|
1280
|
+
//#region src/rules/lifecycle/no-missing-cleanup.ts
|
|
1281
|
+
const NEEDS_CLEANUP = new Set(["setInterval", "addEventListener"]);
|
|
1282
|
+
const noMissingCleanup = {
|
|
1283
|
+
meta: {
|
|
1284
|
+
id: "pyreon/no-missing-cleanup",
|
|
1285
|
+
category: "lifecycle",
|
|
1286
|
+
description: "Warn when onMount uses setInterval/addEventListener without returning a cleanup function.",
|
|
1287
|
+
severity: "warn",
|
|
1288
|
+
fixable: false
|
|
1289
|
+
},
|
|
1290
|
+
create(context) {
|
|
1291
|
+
return { CallExpression(node) {
|
|
1292
|
+
if (!isCallTo(node, "onMount")) return;
|
|
1293
|
+
const args = node.arguments;
|
|
1294
|
+
if (!args || args.length === 0) return;
|
|
1295
|
+
const fn = args[0];
|
|
1296
|
+
if (!fn) return;
|
|
1297
|
+
if (fn.type !== "ArrowFunctionExpression" && fn.type !== "FunctionExpression") return;
|
|
1298
|
+
const body = fn.body;
|
|
1299
|
+
if (!body) return;
|
|
1300
|
+
if (body.type !== "BlockStatement") return;
|
|
1301
|
+
let hasCleanupTarget = false;
|
|
1302
|
+
let hasReturn = false;
|
|
1303
|
+
function walk(n) {
|
|
1304
|
+
if (!n) return;
|
|
1305
|
+
if (n.type === "CallExpression") {
|
|
1306
|
+
const callee = n.callee;
|
|
1307
|
+
if (callee?.type === "Identifier" && NEEDS_CLEANUP.has(callee.name)) hasCleanupTarget = true;
|
|
1308
|
+
if (callee?.type === "MemberExpression" && callee.property?.type === "Identifier" && NEEDS_CLEANUP.has(callee.property.name)) hasCleanupTarget = true;
|
|
1309
|
+
}
|
|
1310
|
+
if (n.type === "ReturnStatement" && n.argument) hasReturn = true;
|
|
1311
|
+
for (const key of Object.keys(n)) {
|
|
1312
|
+
const child = n[key];
|
|
1313
|
+
if (child && typeof child === "object") {
|
|
1314
|
+
if (Array.isArray(child)) {
|
|
1315
|
+
for (const item of child) if (item && typeof item.type === "string") walk(item);
|
|
1316
|
+
} else if (typeof child.type === "string") walk(child);
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
walk(body);
|
|
1321
|
+
if (hasCleanupTarget && !hasReturn) context.report({
|
|
1322
|
+
message: "`onMount` uses `setInterval`/`addEventListener` without returning a cleanup function — this will cause a memory leak.",
|
|
1323
|
+
span: getSpan(node)
|
|
1324
|
+
});
|
|
1325
|
+
} };
|
|
1326
|
+
}
|
|
1327
|
+
};
|
|
1328
|
+
|
|
1329
|
+
//#endregion
|
|
1330
|
+
//#region src/rules/lifecycle/no-mount-in-effect.ts
|
|
1331
|
+
const noMountInEffect = {
|
|
1332
|
+
meta: {
|
|
1333
|
+
id: "pyreon/no-mount-in-effect",
|
|
1334
|
+
category: "lifecycle",
|
|
1335
|
+
description: "Warn when onMount is called inside effect().",
|
|
1336
|
+
severity: "warn",
|
|
1337
|
+
fixable: false
|
|
1338
|
+
},
|
|
1339
|
+
create(context) {
|
|
1340
|
+
let effectDepth = 0;
|
|
1341
|
+
return {
|
|
1342
|
+
CallExpression(node) {
|
|
1343
|
+
if (isCallTo(node, "effect")) effectDepth++;
|
|
1344
|
+
if (effectDepth > 0 && isCallTo(node, "onMount")) context.report({
|
|
1345
|
+
message: "`onMount` inside `effect()` — `onMount` runs once on mount, not on every effect re-run.",
|
|
1346
|
+
span: getSpan(node)
|
|
1347
|
+
});
|
|
1348
|
+
},
|
|
1349
|
+
"CallExpression:exit"(node) {
|
|
1350
|
+
if (isCallTo(node, "effect")) effectDepth--;
|
|
1351
|
+
}
|
|
1352
|
+
};
|
|
1353
|
+
}
|
|
1354
|
+
};
|
|
1355
|
+
|
|
1356
|
+
//#endregion
|
|
1357
|
+
//#region src/rules/performance/no-eager-import.ts
|
|
1358
|
+
const noEagerImport = {
|
|
1359
|
+
meta: {
|
|
1360
|
+
id: "pyreon/no-eager-import",
|
|
1361
|
+
category: "performance",
|
|
1362
|
+
description: "Suggest lazy-loading heavy Pyreon packages (charts, code, document, flow).",
|
|
1363
|
+
severity: "info",
|
|
1364
|
+
fixable: false
|
|
1365
|
+
},
|
|
1366
|
+
create(context) {
|
|
1367
|
+
return { ImportDeclaration(node) {
|
|
1368
|
+
const source = node.source?.value;
|
|
1369
|
+
if (!source) return;
|
|
1370
|
+
if (HEAVY_PACKAGES.has(source)) context.report({
|
|
1371
|
+
message: `Static import of \`${source}\` — consider using \`lazy()\` or dynamic \`import()\` to reduce initial bundle size.`,
|
|
1372
|
+
span: getSpan(node)
|
|
1373
|
+
});
|
|
1374
|
+
} };
|
|
1375
|
+
}
|
|
1376
|
+
};
|
|
1377
|
+
|
|
1378
|
+
//#endregion
|
|
1379
|
+
//#region src/rules/performance/no-effect-in-for.ts
|
|
1380
|
+
const noEffectInFor = {
|
|
1381
|
+
meta: {
|
|
1382
|
+
id: "pyreon/no-effect-in-for",
|
|
1383
|
+
category: "performance",
|
|
1384
|
+
description: "Warn when effect() is created inside <For> — creates effects per item on every reconciliation.",
|
|
1385
|
+
severity: "warn",
|
|
1386
|
+
fixable: false
|
|
1387
|
+
},
|
|
1388
|
+
create(context) {
|
|
1389
|
+
let forJsxDepth = 0;
|
|
1390
|
+
return {
|
|
1391
|
+
JSXOpeningElement(node) {
|
|
1392
|
+
const name = node.name;
|
|
1393
|
+
if (name?.type === "JSXIdentifier" && name.name === "For") forJsxDepth++;
|
|
1394
|
+
},
|
|
1395
|
+
JSXClosingElement(node) {
|
|
1396
|
+
const name = node.name;
|
|
1397
|
+
if (name?.type === "JSXIdentifier" && name.name === "For") forJsxDepth--;
|
|
1398
|
+
},
|
|
1399
|
+
CallExpression(node) {
|
|
1400
|
+
if (forJsxDepth === 0) return;
|
|
1401
|
+
if (isCallTo(node, "effect")) context.report({
|
|
1402
|
+
message: "`effect()` inside `<For>` — this creates a new effect for every item on each reconciliation. Lift the effect outside.",
|
|
1403
|
+
span: getSpan(node)
|
|
1404
|
+
});
|
|
1405
|
+
}
|
|
1406
|
+
};
|
|
1407
|
+
}
|
|
1408
|
+
};
|
|
1409
|
+
|
|
1410
|
+
//#endregion
|
|
1411
|
+
//#region src/rules/performance/no-large-for-without-by.ts
|
|
1412
|
+
const noLargeForWithoutBy = {
|
|
1413
|
+
meta: {
|
|
1414
|
+
id: "pyreon/no-large-for-without-by",
|
|
1415
|
+
category: "performance",
|
|
1416
|
+
description: "Error when <For> is used without a `by` prop — critical for reconciliation performance.",
|
|
1417
|
+
severity: "error",
|
|
1418
|
+
fixable: false
|
|
1419
|
+
},
|
|
1420
|
+
create(context) {
|
|
1421
|
+
return { JSXOpeningElement(node) {
|
|
1422
|
+
const name = node.name;
|
|
1423
|
+
if (!name || name.type !== "JSXIdentifier" || name.name !== "For") return;
|
|
1424
|
+
if (hasJSXAttribute(node, "by")) return;
|
|
1425
|
+
context.report({
|
|
1426
|
+
message: "`<For>` without `by` prop — provide a key function for efficient reconciliation.",
|
|
1427
|
+
span: getSpan(node)
|
|
1428
|
+
});
|
|
1429
|
+
} };
|
|
1430
|
+
}
|
|
1431
|
+
};
|
|
1432
|
+
|
|
1433
|
+
//#endregion
|
|
1434
|
+
//#region src/rules/performance/prefer-show-over-display.ts
|
|
1435
|
+
const preferShowOverDisplay = {
|
|
1436
|
+
meta: {
|
|
1437
|
+
id: "pyreon/prefer-show-over-display",
|
|
1438
|
+
category: "performance",
|
|
1439
|
+
description: "Suggest <Show> over conditional `display` style property in JSX.",
|
|
1440
|
+
severity: "info",
|
|
1441
|
+
fixable: false
|
|
1442
|
+
},
|
|
1443
|
+
create(context) {
|
|
1444
|
+
return { JSXAttribute(node) {
|
|
1445
|
+
if (node.name?.type !== "JSXIdentifier" || node.name.name !== "style") return;
|
|
1446
|
+
const value = node.value;
|
|
1447
|
+
if (!value || value.type !== "JSXExpressionContainer") return;
|
|
1448
|
+
const expr = value.expression;
|
|
1449
|
+
if (!expr || expr.type !== "ObjectExpression") return;
|
|
1450
|
+
for (const prop of expr.properties ?? []) {
|
|
1451
|
+
if (prop.type !== "Property") continue;
|
|
1452
|
+
const key = prop.key;
|
|
1453
|
+
if (!key) continue;
|
|
1454
|
+
if ((key.type === "Identifier" ? key.name : key.type === "Literal" ? key.value : null) === "display") {
|
|
1455
|
+
const val = prop.value;
|
|
1456
|
+
if (val?.type === "ConditionalExpression" || val?.type === "LogicalExpression" || val?.type === "CallExpression") context.report({
|
|
1457
|
+
message: "Conditional `display` style — consider using `<Show>` for conditional rendering instead of toggling CSS display.",
|
|
1458
|
+
span: getSpan(prop)
|
|
1459
|
+
});
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
} };
|
|
1463
|
+
}
|
|
1464
|
+
};
|
|
1465
|
+
|
|
1466
|
+
//#endregion
|
|
1467
|
+
//#region src/rules/reactivity/no-bare-signal-in-jsx.ts
|
|
1468
|
+
const SKIP_PREFIXES = /^(use|get|is|has|[A-Z])/;
|
|
1469
|
+
const noBareSignalInJsx = {
|
|
1470
|
+
meta: {
|
|
1471
|
+
id: "pyreon/no-bare-signal-in-jsx",
|
|
1472
|
+
category: "reactivity",
|
|
1473
|
+
description: "Disallow bare signal calls in JSX text positions. Wrap in `() =>` for reactivity.",
|
|
1474
|
+
severity: "error",
|
|
1475
|
+
fixable: true
|
|
1476
|
+
},
|
|
1477
|
+
create(context) {
|
|
1478
|
+
let jsxDepth = 0;
|
|
1479
|
+
return {
|
|
1480
|
+
JSXElement() {
|
|
1481
|
+
jsxDepth++;
|
|
1482
|
+
},
|
|
1483
|
+
"JSXElement:exit"() {
|
|
1484
|
+
jsxDepth--;
|
|
1485
|
+
},
|
|
1486
|
+
JSXFragment() {
|
|
1487
|
+
jsxDepth++;
|
|
1488
|
+
},
|
|
1489
|
+
"JSXFragment:exit"() {
|
|
1490
|
+
jsxDepth--;
|
|
1491
|
+
},
|
|
1492
|
+
JSXExpressionContainer(node) {
|
|
1493
|
+
if (jsxDepth === 0) return;
|
|
1494
|
+
const expr = node.expression;
|
|
1495
|
+
if (!expr || expr.type !== "CallExpression") return;
|
|
1496
|
+
const callee = expr.callee;
|
|
1497
|
+
if (!callee || callee.type !== "Identifier") return;
|
|
1498
|
+
const name = callee.name;
|
|
1499
|
+
if (SKIP_PREFIXES.test(name)) return;
|
|
1500
|
+
const span = getSpan(node);
|
|
1501
|
+
const fixed = `{() => ${context.getSourceText().slice(span.start, span.end).slice(1, -1)}}`;
|
|
1502
|
+
context.report({
|
|
1503
|
+
message: `Bare signal call \`${name}()\` in JSX text — wrap in \`() => ${name}()\` for fine-grained reactivity.`,
|
|
1504
|
+
span,
|
|
1505
|
+
fix: {
|
|
1506
|
+
span,
|
|
1507
|
+
replacement: fixed
|
|
1508
|
+
}
|
|
1509
|
+
});
|
|
1510
|
+
}
|
|
1511
|
+
};
|
|
1512
|
+
}
|
|
1513
|
+
};
|
|
1514
|
+
|
|
1515
|
+
//#endregion
|
|
1516
|
+
//#region src/rules/reactivity/no-effect-assignment.ts
|
|
1517
|
+
function isUpdateCall(node) {
|
|
1518
|
+
return node.type === "CallExpression" && node.callee?.type === "MemberExpression" && node.callee.property?.type === "Identifier" && node.callee.property.name === "update";
|
|
1519
|
+
}
|
|
1520
|
+
const noEffectAssignment = {
|
|
1521
|
+
meta: {
|
|
1522
|
+
id: "pyreon/no-effect-assignment",
|
|
1523
|
+
category: "reactivity",
|
|
1524
|
+
description: "Warn when an effect only contains a single .update() call.",
|
|
1525
|
+
severity: "warn",
|
|
1526
|
+
fixable: false
|
|
1527
|
+
},
|
|
1528
|
+
create(context) {
|
|
1529
|
+
return { CallExpression(node) {
|
|
1530
|
+
if (!isCallTo(node, "effect")) return;
|
|
1531
|
+
const args = node.arguments;
|
|
1532
|
+
if (!args || args.length === 0) return;
|
|
1533
|
+
const fn = args[0];
|
|
1534
|
+
if (!fn) return;
|
|
1535
|
+
let body = null;
|
|
1536
|
+
if (fn.type === "ArrowFunctionExpression" || fn.type === "FunctionExpression") body = fn.body;
|
|
1537
|
+
if (!body) return;
|
|
1538
|
+
if (isUpdateCall(body)) {
|
|
1539
|
+
context.report({
|
|
1540
|
+
message: "Effect contains a single `.update()` — consider using `computed()` for derived values.",
|
|
1541
|
+
span: getSpan(node)
|
|
1542
|
+
});
|
|
1543
|
+
return;
|
|
1544
|
+
}
|
|
1545
|
+
if (body.type === "BlockStatement") {
|
|
1546
|
+
const stmts = body.body;
|
|
1547
|
+
if (stmts && stmts.length === 1) {
|
|
1548
|
+
const stmt = stmts[0];
|
|
1549
|
+
if (stmt.type === "ExpressionStatement" && isUpdateCall(stmt.expression)) context.report({
|
|
1550
|
+
message: "Effect contains a single `.update()` — consider using `computed()` for derived values.",
|
|
1551
|
+
span: getSpan(node)
|
|
1552
|
+
});
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
} };
|
|
1556
|
+
}
|
|
1557
|
+
};
|
|
1558
|
+
|
|
1559
|
+
//#endregion
|
|
1560
|
+
//#region src/rules/reactivity/no-nested-effect.ts
|
|
1561
|
+
const noNestedEffect = {
|
|
1562
|
+
meta: {
|
|
1563
|
+
id: "pyreon/no-nested-effect",
|
|
1564
|
+
category: "reactivity",
|
|
1565
|
+
description: "Warn against nesting effect() inside another effect().",
|
|
1566
|
+
severity: "warn",
|
|
1567
|
+
fixable: false
|
|
1568
|
+
},
|
|
1569
|
+
create(context) {
|
|
1570
|
+
let effectDepth = 0;
|
|
1571
|
+
return {
|
|
1572
|
+
CallExpression(node) {
|
|
1573
|
+
if (!isCallTo(node, "effect")) return;
|
|
1574
|
+
if (effectDepth > 0) context.report({
|
|
1575
|
+
message: "Nested `effect()` — consider using `computed()` for derived values instead.",
|
|
1576
|
+
span: getSpan(node)
|
|
1577
|
+
});
|
|
1578
|
+
effectDepth++;
|
|
1579
|
+
},
|
|
1580
|
+
"CallExpression:exit"(node) {
|
|
1581
|
+
if (isCallTo(node, "effect")) effectDepth--;
|
|
1582
|
+
}
|
|
1583
|
+
};
|
|
1584
|
+
}
|
|
1585
|
+
};
|
|
1586
|
+
|
|
1587
|
+
//#endregion
|
|
1588
|
+
//#region src/rules/reactivity/no-peek-in-tracked.ts
|
|
1589
|
+
const noPeekInTracked = {
|
|
1590
|
+
meta: {
|
|
1591
|
+
id: "pyreon/no-peek-in-tracked",
|
|
1592
|
+
category: "reactivity",
|
|
1593
|
+
description: "Disallow .peek() inside effect() or computed() — it bypasses tracking.",
|
|
1594
|
+
severity: "error",
|
|
1595
|
+
fixable: false
|
|
1596
|
+
},
|
|
1597
|
+
create(context) {
|
|
1598
|
+
let trackedDepth = 0;
|
|
1599
|
+
return {
|
|
1600
|
+
CallExpression(node) {
|
|
1601
|
+
if (isCallTo(node, "effect") || isCallTo(node, "computed")) trackedDepth++;
|
|
1602
|
+
if (trackedDepth > 0 && isPeekCall(node)) context.report({
|
|
1603
|
+
message: "`.peek()` inside a tracked scope (effect/computed) bypasses dependency tracking — use a normal signal read instead.",
|
|
1604
|
+
span: getSpan(node)
|
|
1605
|
+
});
|
|
1606
|
+
},
|
|
1607
|
+
"CallExpression:exit"(node) {
|
|
1608
|
+
if (isCallTo(node, "effect") || isCallTo(node, "computed")) trackedDepth--;
|
|
1609
|
+
}
|
|
1610
|
+
};
|
|
1611
|
+
}
|
|
1612
|
+
};
|
|
1613
|
+
|
|
1614
|
+
//#endregion
|
|
1615
|
+
//#region src/rules/reactivity/no-signal-in-loop.ts
|
|
1616
|
+
const noSignalInLoop = {
|
|
1617
|
+
meta: {
|
|
1618
|
+
id: "pyreon/no-signal-in-loop",
|
|
1619
|
+
category: "reactivity",
|
|
1620
|
+
description: "Disallow creating signals or computeds inside loops.",
|
|
1621
|
+
severity: "error",
|
|
1622
|
+
fixable: false
|
|
1623
|
+
},
|
|
1624
|
+
create(context) {
|
|
1625
|
+
let loopDepth = 0;
|
|
1626
|
+
return {
|
|
1627
|
+
ForStatement() {
|
|
1628
|
+
loopDepth++;
|
|
1629
|
+
},
|
|
1630
|
+
"ForStatement:exit"() {
|
|
1631
|
+
loopDepth--;
|
|
1632
|
+
},
|
|
1633
|
+
ForInStatement() {
|
|
1634
|
+
loopDepth++;
|
|
1635
|
+
},
|
|
1636
|
+
"ForInStatement:exit"() {
|
|
1637
|
+
loopDepth--;
|
|
1638
|
+
},
|
|
1639
|
+
ForOfStatement() {
|
|
1640
|
+
loopDepth++;
|
|
1641
|
+
},
|
|
1642
|
+
"ForOfStatement:exit"() {
|
|
1643
|
+
loopDepth--;
|
|
1644
|
+
},
|
|
1645
|
+
WhileStatement() {
|
|
1646
|
+
loopDepth++;
|
|
1647
|
+
},
|
|
1648
|
+
"WhileStatement:exit"() {
|
|
1649
|
+
loopDepth--;
|
|
1650
|
+
},
|
|
1651
|
+
DoWhileStatement() {
|
|
1652
|
+
loopDepth++;
|
|
1653
|
+
},
|
|
1654
|
+
"DoWhileStatement:exit"() {
|
|
1655
|
+
loopDepth--;
|
|
1656
|
+
},
|
|
1657
|
+
CallExpression(node) {
|
|
1658
|
+
if (loopDepth === 0) return;
|
|
1659
|
+
const callee = node.callee;
|
|
1660
|
+
if (!callee || callee.type !== "Identifier") return;
|
|
1661
|
+
if (callee.name === "signal" || callee.name === "computed") context.report({
|
|
1662
|
+
message: `\`${callee.name}()\` inside a loop — signals should be created once at component setup, not on every iteration.`,
|
|
1663
|
+
span: getSpan(node)
|
|
1664
|
+
});
|
|
1665
|
+
}
|
|
1666
|
+
};
|
|
1667
|
+
}
|
|
1668
|
+
};
|
|
1669
|
+
|
|
1670
|
+
//#endregion
|
|
1671
|
+
//#region src/rules/reactivity/no-signal-leak.ts
|
|
1672
|
+
const noSignalLeak = {
|
|
1673
|
+
meta: {
|
|
1674
|
+
id: "pyreon/no-signal-leak",
|
|
1675
|
+
category: "reactivity",
|
|
1676
|
+
description: "Warn about unused signal declarations (potential leaks).",
|
|
1677
|
+
severity: "warn",
|
|
1678
|
+
fixable: false
|
|
1679
|
+
},
|
|
1680
|
+
create(context) {
|
|
1681
|
+
const signalDecls = /* @__PURE__ */ new Map();
|
|
1682
|
+
const identifierOccurrences = /* @__PURE__ */ new Map();
|
|
1683
|
+
return {
|
|
1684
|
+
VariableDeclarator(node) {
|
|
1685
|
+
const init = node.init;
|
|
1686
|
+
if (!init || !isCallTo(init, "signal")) return;
|
|
1687
|
+
const id = node.id;
|
|
1688
|
+
if (!id || id.type !== "Identifier") return;
|
|
1689
|
+
signalDecls.set(id.name, {
|
|
1690
|
+
span: getSpan(node),
|
|
1691
|
+
declStart: id.start,
|
|
1692
|
+
declEnd: id.end
|
|
1693
|
+
});
|
|
1694
|
+
},
|
|
1695
|
+
Identifier(node) {
|
|
1696
|
+
const name = node.name;
|
|
1697
|
+
const existing = identifierOccurrences.get(name);
|
|
1698
|
+
if (existing) existing.push({
|
|
1699
|
+
start: node.start,
|
|
1700
|
+
end: node.end
|
|
1701
|
+
});
|
|
1702
|
+
else identifierOccurrences.set(name, [{
|
|
1703
|
+
start: node.start,
|
|
1704
|
+
end: node.end
|
|
1705
|
+
}]);
|
|
1706
|
+
},
|
|
1707
|
+
"Program:exit"() {
|
|
1708
|
+
for (const [name, { span, declStart, declEnd }] of signalDecls) if ((identifierOccurrences.get(name) ?? []).filter((o) => o.start !== declStart || o.end !== declEnd).length === 0) context.report({
|
|
1709
|
+
message: `Signal \`${name}\` is declared but never used — this may be a signal leak.`,
|
|
1710
|
+
span
|
|
1711
|
+
});
|
|
1712
|
+
}
|
|
1713
|
+
};
|
|
1714
|
+
}
|
|
1715
|
+
};
|
|
1716
|
+
|
|
1717
|
+
//#endregion
|
|
1718
|
+
//#region src/rules/reactivity/no-unbatched-updates.ts
|
|
1719
|
+
const noUnbatchedUpdates = {
|
|
1720
|
+
meta: {
|
|
1721
|
+
id: "pyreon/no-unbatched-updates",
|
|
1722
|
+
category: "reactivity",
|
|
1723
|
+
description: "Warn when 3+ .set() calls occur in the same function without batch().",
|
|
1724
|
+
severity: "warn",
|
|
1725
|
+
fixable: false
|
|
1726
|
+
},
|
|
1727
|
+
create(context) {
|
|
1728
|
+
const scopeStack = [];
|
|
1729
|
+
let batchDepth = 0;
|
|
1730
|
+
function enterScope(node) {
|
|
1731
|
+
scopeStack.push({
|
|
1732
|
+
setCalls: [],
|
|
1733
|
+
hasBatch: false,
|
|
1734
|
+
insideBatch: batchDepth > 0,
|
|
1735
|
+
node
|
|
1736
|
+
});
|
|
1737
|
+
}
|
|
1738
|
+
function exitScope() {
|
|
1739
|
+
const scope = scopeStack.pop();
|
|
1740
|
+
if (!scope) return;
|
|
1741
|
+
if (!scope.hasBatch && !scope.insideBatch && scope.setCalls.length >= 3) context.report({
|
|
1742
|
+
message: `${scope.setCalls.length} signal \`.set()\` calls without \`batch()\` — wrap in \`batch(() => { ... })\` to avoid unnecessary re-renders.`,
|
|
1743
|
+
span: getSpan(scope.node)
|
|
1744
|
+
});
|
|
1745
|
+
}
|
|
1746
|
+
return {
|
|
1747
|
+
FunctionDeclaration(node) {
|
|
1748
|
+
enterScope(node);
|
|
1749
|
+
},
|
|
1750
|
+
"FunctionDeclaration:exit"() {
|
|
1751
|
+
exitScope();
|
|
1752
|
+
},
|
|
1753
|
+
FunctionExpression(node) {
|
|
1754
|
+
enterScope(node);
|
|
1755
|
+
},
|
|
1756
|
+
"FunctionExpression:exit"() {
|
|
1757
|
+
exitScope();
|
|
1758
|
+
},
|
|
1759
|
+
ArrowFunctionExpression(node) {
|
|
1760
|
+
enterScope(node);
|
|
1761
|
+
},
|
|
1762
|
+
"ArrowFunctionExpression:exit"() {
|
|
1763
|
+
exitScope();
|
|
1764
|
+
},
|
|
1765
|
+
CallExpression(node) {
|
|
1766
|
+
const currentScope = scopeStack.length > 0 ? scopeStack[scopeStack.length - 1] : void 0;
|
|
1767
|
+
if (isCallTo(node, "batch")) {
|
|
1768
|
+
batchDepth++;
|
|
1769
|
+
if (currentScope) currentScope.hasBatch = true;
|
|
1770
|
+
}
|
|
1771
|
+
if (currentScope && isSetCall(node)) currentScope.setCalls.push({ span: getSpan(node) });
|
|
1772
|
+
},
|
|
1773
|
+
"CallExpression:exit"(node) {
|
|
1774
|
+
if (isCallTo(node, "batch")) batchDepth--;
|
|
1775
|
+
}
|
|
1776
|
+
};
|
|
1777
|
+
}
|
|
1778
|
+
};
|
|
1779
|
+
|
|
1780
|
+
//#endregion
|
|
1781
|
+
//#region src/rules/reactivity/prefer-computed.ts
|
|
1782
|
+
const preferComputed = {
|
|
1783
|
+
meta: {
|
|
1784
|
+
id: "pyreon/prefer-computed",
|
|
1785
|
+
category: "reactivity",
|
|
1786
|
+
description: "Suggest computed() when an effect only contains a single .set() call.",
|
|
1787
|
+
severity: "warn",
|
|
1788
|
+
fixable: false
|
|
1789
|
+
},
|
|
1790
|
+
create(context) {
|
|
1791
|
+
return { CallExpression(node) {
|
|
1792
|
+
if (!isCallTo(node, "effect")) return;
|
|
1793
|
+
const args = node.arguments;
|
|
1794
|
+
if (!args || args.length === 0) return;
|
|
1795
|
+
const fn = args[0];
|
|
1796
|
+
if (!fn) return;
|
|
1797
|
+
let body = null;
|
|
1798
|
+
if (fn.type === "ArrowFunctionExpression" || fn.type === "FunctionExpression") body = fn.body;
|
|
1799
|
+
if (!body) return;
|
|
1800
|
+
if (body.type === "CallExpression" && isSetCall(body)) {
|
|
1801
|
+
context.report({
|
|
1802
|
+
message: "Effect contains a single `.set()` — consider using `computed()` instead for derived values.",
|
|
1803
|
+
span: getSpan(node)
|
|
1804
|
+
});
|
|
1805
|
+
return;
|
|
1806
|
+
}
|
|
1807
|
+
if (body.type === "BlockStatement") {
|
|
1808
|
+
const stmts = body.body;
|
|
1809
|
+
if (stmts && stmts.length === 1) {
|
|
1810
|
+
const stmt = stmts[0];
|
|
1811
|
+
if (stmt.type === "ExpressionStatement" && isSetCall(stmt.expression)) context.report({
|
|
1812
|
+
message: "Effect contains a single `.set()` — consider using `computed()` instead for derived values.",
|
|
1813
|
+
span: getSpan(node)
|
|
1814
|
+
});
|
|
1815
|
+
}
|
|
1816
|
+
}
|
|
1817
|
+
} };
|
|
1818
|
+
}
|
|
1819
|
+
};
|
|
1820
|
+
|
|
1821
|
+
//#endregion
|
|
1822
|
+
//#region src/rules/router/no-href-navigation.ts
|
|
1823
|
+
const EXTERNAL_PREFIXES = [
|
|
1824
|
+
"http://",
|
|
1825
|
+
"https://",
|
|
1826
|
+
"mailto:",
|
|
1827
|
+
"tel:"
|
|
1828
|
+
];
|
|
1829
|
+
const noHrefNavigation = {
|
|
1830
|
+
meta: {
|
|
1831
|
+
id: "pyreon/no-href-navigation",
|
|
1832
|
+
category: "router",
|
|
1833
|
+
description: "Warn when `<a href>` is used in files that import @pyreon/router — use `<Link>` instead.",
|
|
1834
|
+
severity: "warn",
|
|
1835
|
+
fixable: false
|
|
1836
|
+
},
|
|
1837
|
+
create(context) {
|
|
1838
|
+
let importsRouter = false;
|
|
1839
|
+
return {
|
|
1840
|
+
ImportDeclaration(node) {
|
|
1841
|
+
const info = extractImportInfo(node);
|
|
1842
|
+
if (info && info.source === "@pyreon/router") importsRouter = true;
|
|
1843
|
+
},
|
|
1844
|
+
JSXOpeningElement(node) {
|
|
1845
|
+
if (!importsRouter) return;
|
|
1846
|
+
const name = node.name;
|
|
1847
|
+
if (!name || name.type !== "JSXIdentifier" || name.name !== "a") return;
|
|
1848
|
+
const hrefAttr = getJSXAttribute(node, "href");
|
|
1849
|
+
if (!hrefAttr) return;
|
|
1850
|
+
const value = hrefAttr.value;
|
|
1851
|
+
if (value?.type === "Literal" && typeof value.value === "string") {
|
|
1852
|
+
const href = value.value;
|
|
1853
|
+
if (href.startsWith("#") || EXTERNAL_PREFIXES.some((p) => href.startsWith(p))) return;
|
|
1854
|
+
}
|
|
1855
|
+
context.report({
|
|
1856
|
+
message: "`<a href>` in a router file — use `<Link>` or `<RouterLink>` for client-side navigation.",
|
|
1857
|
+
span: getSpan(node)
|
|
1858
|
+
});
|
|
1859
|
+
}
|
|
1860
|
+
};
|
|
1861
|
+
}
|
|
1862
|
+
};
|
|
1863
|
+
|
|
1864
|
+
//#endregion
|
|
1865
|
+
//#region src/rules/router/no-imperative-navigate-in-render.ts
|
|
1866
|
+
const noImperativeNavigateInRender = {
|
|
1867
|
+
meta: {
|
|
1868
|
+
id: "pyreon/no-imperative-navigate-in-render",
|
|
1869
|
+
category: "router",
|
|
1870
|
+
description: "Error when navigate() or router.push() is called at the top level of a component — causes infinite render loops.",
|
|
1871
|
+
severity: "error",
|
|
1872
|
+
fixable: false
|
|
1873
|
+
},
|
|
1874
|
+
create(context) {
|
|
1875
|
+
let componentBodyDepth = 0;
|
|
1876
|
+
let safeDepth = 0;
|
|
1877
|
+
return {
|
|
1878
|
+
FunctionDeclaration(node) {
|
|
1879
|
+
const name = node.id?.name ?? "";
|
|
1880
|
+
if (/^[A-Z]/.test(name)) componentBodyDepth++;
|
|
1881
|
+
},
|
|
1882
|
+
"FunctionDeclaration:exit"(node) {
|
|
1883
|
+
const name = node.id?.name ?? "";
|
|
1884
|
+
if (/^[A-Z]/.test(name)) componentBodyDepth--;
|
|
1885
|
+
},
|
|
1886
|
+
VariableDeclarator(node) {
|
|
1887
|
+
const name = node.id?.name ?? "";
|
|
1888
|
+
if (/^[A-Z]/.test(name) && node.init?.type === "ArrowFunctionExpression") componentBodyDepth++;
|
|
1889
|
+
},
|
|
1890
|
+
"VariableDeclarator:exit"(node) {
|
|
1891
|
+
const name = node.id?.name ?? "";
|
|
1892
|
+
if (/^[A-Z]/.test(name) && node.init?.type === "ArrowFunctionExpression") componentBodyDepth--;
|
|
1893
|
+
},
|
|
1894
|
+
CallExpression(node) {
|
|
1895
|
+
if (componentBodyDepth <= 0) return;
|
|
1896
|
+
if (isSafeWrapperCall(node)) safeDepth++;
|
|
1897
|
+
if (safeDepth > 0) return;
|
|
1898
|
+
if (isCallTo(node, "navigate") || isMemberCallTo(node, "router", "push")) context.report({
|
|
1899
|
+
message: "Imperative navigation at the top level of a component — this runs on every render and causes infinite loops. Move inside `onMount`, `effect`, or an event handler.",
|
|
1900
|
+
span: getSpan(node)
|
|
1901
|
+
});
|
|
1902
|
+
},
|
|
1903
|
+
"CallExpression:exit"(node) {
|
|
1904
|
+
if (componentBodyDepth <= 0) return;
|
|
1905
|
+
if (isSafeWrapperCall(node)) safeDepth--;
|
|
1906
|
+
}
|
|
1907
|
+
};
|
|
1908
|
+
}
|
|
1909
|
+
};
|
|
1910
|
+
function isSafeWrapperCall(node) {
|
|
1911
|
+
const callee = node.callee;
|
|
1912
|
+
if (!callee || callee.type !== "Identifier") return false;
|
|
1913
|
+
const name = callee.name;
|
|
1914
|
+
return name === "onMount" || name === "effect" || name === "onUnmount";
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
//#endregion
|
|
1918
|
+
//#region src/rules/router/no-missing-fallback.ts
|
|
1919
|
+
function isCatchAllPath(value) {
|
|
1920
|
+
return value === "*" || value.endsWith("*");
|
|
1921
|
+
}
|
|
1922
|
+
function getPathValue(prop) {
|
|
1923
|
+
const key = prop.key;
|
|
1924
|
+
if (!key) return null;
|
|
1925
|
+
if ((key.type === "Identifier" ? key.name : null) !== "path") return null;
|
|
1926
|
+
const val = prop.value;
|
|
1927
|
+
if (val?.type === "Literal" && typeof val.value === "string") return val.value;
|
|
1928
|
+
return null;
|
|
1929
|
+
}
|
|
1930
|
+
function hasPathProperty(obj) {
|
|
1931
|
+
if (!obj || obj.type !== "ObjectExpression") return false;
|
|
1932
|
+
for (const prop of obj.properties ?? []) {
|
|
1933
|
+
if (prop.type !== "Property") continue;
|
|
1934
|
+
if (getPathValue(prop) !== null) return true;
|
|
1935
|
+
}
|
|
1936
|
+
return false;
|
|
1937
|
+
}
|
|
1938
|
+
function hasCatchAllRoute(elements) {
|
|
1939
|
+
for (const elem of elements) {
|
|
1940
|
+
if (!elem || elem.type !== "ObjectExpression") continue;
|
|
1941
|
+
for (const prop of elem.properties ?? []) {
|
|
1942
|
+
if (prop.type !== "Property") continue;
|
|
1943
|
+
const pathVal = getPathValue(prop);
|
|
1944
|
+
if (pathVal !== null && isCatchAllPath(pathVal)) return true;
|
|
1945
|
+
}
|
|
1946
|
+
}
|
|
1947
|
+
return false;
|
|
1948
|
+
}
|
|
1949
|
+
const noMissingFallback = {
|
|
1950
|
+
meta: {
|
|
1951
|
+
id: "pyreon/no-missing-fallback",
|
|
1952
|
+
category: "router",
|
|
1953
|
+
description: "Warn when route config has no catch-all route (`path: \"*\"` or `path: \"/:rest*\"`).",
|
|
1954
|
+
severity: "warn",
|
|
1955
|
+
fixable: false
|
|
1956
|
+
},
|
|
1957
|
+
create(context) {
|
|
1958
|
+
let importsRouter = false;
|
|
1959
|
+
let routeArraySpan = null;
|
|
1960
|
+
let foundCatchAll = false;
|
|
1961
|
+
return {
|
|
1962
|
+
ImportDeclaration(node) {
|
|
1963
|
+
const info = extractImportInfo(node);
|
|
1964
|
+
if (info && info.source === "@pyreon/router") importsRouter = true;
|
|
1965
|
+
},
|
|
1966
|
+
ArrayExpression(node) {
|
|
1967
|
+
if (!importsRouter) return;
|
|
1968
|
+
const elements = node.elements ?? [];
|
|
1969
|
+
if (!elements.some((e) => hasPathProperty(e))) return;
|
|
1970
|
+
if (!routeArraySpan) routeArraySpan = getSpan(node);
|
|
1971
|
+
if (hasCatchAllRoute(elements)) foundCatchAll = true;
|
|
1972
|
+
},
|
|
1973
|
+
"Program:exit"() {
|
|
1974
|
+
if (!importsRouter || !routeArraySpan || foundCatchAll) return;
|
|
1975
|
+
context.report({
|
|
1976
|
+
message: "Route config has no catch-all route — add a `{ path: \"*\", component: NotFound }` for unmatched URLs.",
|
|
1977
|
+
span: routeArraySpan
|
|
1978
|
+
});
|
|
1979
|
+
}
|
|
1980
|
+
};
|
|
1981
|
+
}
|
|
1982
|
+
};
|
|
1983
|
+
|
|
1984
|
+
//#endregion
|
|
1985
|
+
//#region src/rules/router/prefer-use-is-active.ts
|
|
1986
|
+
const preferUseIsActive = {
|
|
1987
|
+
meta: {
|
|
1988
|
+
id: "pyreon/prefer-use-is-active",
|
|
1989
|
+
category: "router",
|
|
1990
|
+
description: "Suggest useIsActive() instead of `location.pathname === \"/foo\"` or `route.path === \"/foo\"` patterns.",
|
|
1991
|
+
severity: "info",
|
|
1992
|
+
fixable: false
|
|
1993
|
+
},
|
|
1994
|
+
create(context) {
|
|
1995
|
+
return { BinaryExpression(node) {
|
|
1996
|
+
if (node.operator !== "===" && node.operator !== "==") return;
|
|
1997
|
+
if (isPathComparison(node.left) || isPathComparison(node.right)) context.report({
|
|
1998
|
+
message: "Manual path comparison — use `useIsActive()` for reactive route matching with segment-aware prefix matching.",
|
|
1999
|
+
span: getSpan(node)
|
|
2000
|
+
});
|
|
2001
|
+
} };
|
|
2002
|
+
}
|
|
2003
|
+
};
|
|
2004
|
+
function isPathComparison(node) {
|
|
2005
|
+
if (!node || node.type !== "MemberExpression") return false;
|
|
2006
|
+
const obj = node.object;
|
|
2007
|
+
const prop = node.property;
|
|
2008
|
+
if (!obj || !prop || prop.type !== "Identifier") return false;
|
|
2009
|
+
if (obj.type === "Identifier" && obj.name === "location" && prop.name === "pathname") return true;
|
|
2010
|
+
if (obj.type === "Identifier" && obj.name === "route" && prop.name === "path") return true;
|
|
2011
|
+
return false;
|
|
2012
|
+
}
|
|
2013
|
+
|
|
2014
|
+
//#endregion
|
|
2015
|
+
//#region src/rules/ssr/no-mismatch-risk.ts
|
|
2016
|
+
const noMismatchRisk = {
|
|
2017
|
+
meta: {
|
|
2018
|
+
id: "pyreon/no-mismatch-risk",
|
|
2019
|
+
category: "ssr",
|
|
2020
|
+
description: "Warn about non-deterministic calls (Date.now, Math.random, crypto.randomUUID) in JSX context that cause hydration mismatches.",
|
|
2021
|
+
severity: "warn",
|
|
2022
|
+
fixable: false
|
|
2023
|
+
},
|
|
2024
|
+
create(context) {
|
|
2025
|
+
let jsxDepth = 0;
|
|
2026
|
+
return {
|
|
2027
|
+
JSXElement() {
|
|
2028
|
+
jsxDepth++;
|
|
2029
|
+
},
|
|
2030
|
+
"JSXElement:exit"() {
|
|
2031
|
+
jsxDepth--;
|
|
2032
|
+
},
|
|
2033
|
+
JSXFragment() {
|
|
2034
|
+
jsxDepth++;
|
|
2035
|
+
},
|
|
2036
|
+
"JSXFragment:exit"() {
|
|
2037
|
+
jsxDepth--;
|
|
2038
|
+
},
|
|
2039
|
+
CallExpression(node) {
|
|
2040
|
+
if (jsxDepth === 0) return;
|
|
2041
|
+
if (isMemberCallTo(node, "Date", "now") || isMemberCallTo(node, "Math", "random") || isMemberCallTo(node, "crypto", "randomUUID")) {
|
|
2042
|
+
const callee = node.callee;
|
|
2043
|
+
const name = `${callee.object.name}.${callee.property.name}`;
|
|
2044
|
+
context.report({
|
|
2045
|
+
message: `\`${name}()\` in JSX context — this produces different values on server and client, causing hydration mismatches.`,
|
|
2046
|
+
span: getSpan(node)
|
|
2047
|
+
});
|
|
2048
|
+
}
|
|
2049
|
+
}
|
|
2050
|
+
};
|
|
2051
|
+
}
|
|
2052
|
+
};
|
|
2053
|
+
|
|
2054
|
+
//#endregion
|
|
2055
|
+
//#region src/rules/ssr/no-window-in-ssr.ts
|
|
2056
|
+
const noWindowInSsr = {
|
|
2057
|
+
meta: {
|
|
2058
|
+
id: "pyreon/no-window-in-ssr",
|
|
2059
|
+
category: "ssr",
|
|
2060
|
+
description: "Disallow browser globals outside onMount/effect/typeof guards — they break SSR.",
|
|
2061
|
+
severity: "error",
|
|
2062
|
+
fixable: false
|
|
2063
|
+
},
|
|
2064
|
+
create(context) {
|
|
2065
|
+
let safeDepth = 0;
|
|
2066
|
+
let typeofGuardDepth = 0;
|
|
2067
|
+
return {
|
|
2068
|
+
CallExpression(node) {
|
|
2069
|
+
if (isCallTo(node, "onMount") || isCallTo(node, "effect")) safeDepth++;
|
|
2070
|
+
},
|
|
2071
|
+
"CallExpression:exit"(node) {
|
|
2072
|
+
if (isCallTo(node, "onMount") || isCallTo(node, "effect")) safeDepth--;
|
|
2073
|
+
},
|
|
2074
|
+
IfStatement(node) {
|
|
2075
|
+
const test = node.test;
|
|
2076
|
+
if (test?.type === "BinaryExpression" && test.left?.type === "UnaryExpression" && test.left.operator === "typeof") typeofGuardDepth++;
|
|
2077
|
+
},
|
|
2078
|
+
"IfStatement:exit"(node) {
|
|
2079
|
+
const test = node.test;
|
|
2080
|
+
if (test?.type === "BinaryExpression" && test.left?.type === "UnaryExpression" && test.left.operator === "typeof") typeofGuardDepth--;
|
|
2081
|
+
},
|
|
2082
|
+
Identifier(node, parent) {
|
|
2083
|
+
if (safeDepth > 0 || typeofGuardDepth > 0) return;
|
|
2084
|
+
if (!BROWSER_GLOBALS.has(node.name)) return;
|
|
2085
|
+
if (parent?.type === "UnaryExpression" && parent.operator === "typeof") return;
|
|
2086
|
+
if (parent?.type === "ImportSpecifier" || parent?.type === "ImportDefaultSpecifier" || parent?.type === "ImportNamespaceSpecifier") return;
|
|
2087
|
+
if (parent?.type === "MemberExpression" && parent.property === node && !parent.computed) return;
|
|
2088
|
+
context.report({
|
|
2089
|
+
message: `Browser global \`${node.name}\` used outside \`onMount\`/\`effect\`/typeof guard — this will fail during SSR. Wrap in \`onMount(() => { ... })\`.`,
|
|
2090
|
+
span: getSpan(node)
|
|
2091
|
+
});
|
|
2092
|
+
}
|
|
2093
|
+
};
|
|
2094
|
+
}
|
|
2095
|
+
};
|
|
2096
|
+
|
|
2097
|
+
//#endregion
|
|
2098
|
+
//#region src/rules/ssr/prefer-request-context.ts
|
|
2099
|
+
const preferRequestContext = {
|
|
2100
|
+
meta: {
|
|
2101
|
+
id: "pyreon/prefer-request-context",
|
|
2102
|
+
category: "ssr",
|
|
2103
|
+
description: "Warn about module-level signal()/createStore() in server files — use request context instead.",
|
|
2104
|
+
severity: "warn",
|
|
2105
|
+
fixable: false
|
|
2106
|
+
},
|
|
2107
|
+
create(context) {
|
|
2108
|
+
const filePath = context.getFilePath();
|
|
2109
|
+
if (!(filePath.includes("server") || filePath.includes(".server.") || filePath.endsWith("server.ts") || filePath.endsWith("server.tsx"))) return {};
|
|
2110
|
+
let functionDepth = 0;
|
|
2111
|
+
return {
|
|
2112
|
+
FunctionDeclaration() {
|
|
2113
|
+
functionDepth++;
|
|
2114
|
+
},
|
|
2115
|
+
"FunctionDeclaration:exit"() {
|
|
2116
|
+
functionDepth--;
|
|
2117
|
+
},
|
|
2118
|
+
FunctionExpression() {
|
|
2119
|
+
functionDepth++;
|
|
2120
|
+
},
|
|
2121
|
+
"FunctionExpression:exit"() {
|
|
2122
|
+
functionDepth--;
|
|
2123
|
+
},
|
|
2124
|
+
ArrowFunctionExpression() {
|
|
2125
|
+
functionDepth++;
|
|
2126
|
+
},
|
|
2127
|
+
"ArrowFunctionExpression:exit"() {
|
|
2128
|
+
functionDepth--;
|
|
2129
|
+
},
|
|
2130
|
+
CallExpression(node) {
|
|
2131
|
+
if (functionDepth > 0) return;
|
|
2132
|
+
if (isCallTo(node, "signal") || isCallTo(node, "createStore")) {
|
|
2133
|
+
const name = node.callee.name;
|
|
2134
|
+
context.report({
|
|
2135
|
+
message: `Module-level \`${name}()\` in a server file — this state is shared across all requests. Use \`runWithRequestContext()\` for per-request isolation.`,
|
|
2136
|
+
span: getSpan(node)
|
|
2137
|
+
});
|
|
2138
|
+
}
|
|
2139
|
+
}
|
|
2140
|
+
};
|
|
2141
|
+
}
|
|
2142
|
+
};
|
|
2143
|
+
|
|
2144
|
+
//#endregion
|
|
2145
|
+
//#region src/rules/store/no-duplicate-store-id.ts
|
|
2146
|
+
const noDuplicateStoreId = {
|
|
2147
|
+
meta: {
|
|
2148
|
+
id: "pyreon/no-duplicate-store-id",
|
|
2149
|
+
category: "store",
|
|
2150
|
+
description: "Disallow duplicate defineStore() IDs in the same file.",
|
|
2151
|
+
severity: "error",
|
|
2152
|
+
fixable: false
|
|
2153
|
+
},
|
|
2154
|
+
create(context) {
|
|
2155
|
+
const storeIds = /* @__PURE__ */ new Map();
|
|
2156
|
+
return { CallExpression(node) {
|
|
2157
|
+
if (!isCallTo(node, "defineStore")) return;
|
|
2158
|
+
const args = node.arguments;
|
|
2159
|
+
if (!args || args.length === 0) return;
|
|
2160
|
+
const firstArg = args[0];
|
|
2161
|
+
if (!firstArg) return;
|
|
2162
|
+
let id = null;
|
|
2163
|
+
if (firstArg.type === "Literal" || firstArg.type === "StringLiteral") id = firstArg.value;
|
|
2164
|
+
if (typeof id !== "string") return;
|
|
2165
|
+
if (storeIds.has(id)) context.report({
|
|
2166
|
+
message: `Duplicate store ID \`"${id}"\` — each \`defineStore()\` must have a unique ID.`,
|
|
2167
|
+
span: getSpan(node)
|
|
2168
|
+
});
|
|
2169
|
+
else storeIds.set(id, getSpan(node));
|
|
2170
|
+
} };
|
|
2171
|
+
}
|
|
2172
|
+
};
|
|
2173
|
+
|
|
2174
|
+
//#endregion
|
|
2175
|
+
//#region src/rules/store/no-mutate-store-state.ts
|
|
2176
|
+
const noMutateStoreState = {
|
|
2177
|
+
meta: {
|
|
2178
|
+
id: "pyreon/no-mutate-store-state",
|
|
2179
|
+
category: "store",
|
|
2180
|
+
description: "Warn when directly calling .set() on store signals — use store actions instead.",
|
|
2181
|
+
severity: "warn",
|
|
2182
|
+
fixable: false
|
|
2183
|
+
},
|
|
2184
|
+
create(context) {
|
|
2185
|
+
return { CallExpression(node) {
|
|
2186
|
+
const callee = node.callee;
|
|
2187
|
+
if (!callee || callee.type !== "MemberExpression") return;
|
|
2188
|
+
if (callee.property?.type !== "Identifier" || callee.property.name !== "set") return;
|
|
2189
|
+
const obj = callee.object;
|
|
2190
|
+
if (!obj || obj.type !== "MemberExpression") return;
|
|
2191
|
+
const outerObj = obj.object;
|
|
2192
|
+
if (!outerObj || outerObj.type !== "Identifier") return;
|
|
2193
|
+
const name = outerObj.name;
|
|
2194
|
+
if (name.toLowerCase().includes("store")) context.report({
|
|
2195
|
+
message: `Direct \`.set()\` on store state \`${name}\` — use store actions to mutate state for better traceability.`,
|
|
2196
|
+
span: getSpan(node)
|
|
2197
|
+
});
|
|
2198
|
+
} };
|
|
2199
|
+
}
|
|
2200
|
+
};
|
|
2201
|
+
|
|
2202
|
+
//#endregion
|
|
2203
|
+
//#region src/rules/store/no-store-outside-provider.ts
|
|
2204
|
+
const noStoreOutsideProvider = {
|
|
2205
|
+
meta: {
|
|
2206
|
+
id: "pyreon/no-store-outside-provider",
|
|
2207
|
+
category: "store",
|
|
2208
|
+
description: "Warn when store hooks are used in SSR files without a provider import.",
|
|
2209
|
+
severity: "warn",
|
|
2210
|
+
fixable: false
|
|
2211
|
+
},
|
|
2212
|
+
create(context) {
|
|
2213
|
+
const filePath = context.getFilePath();
|
|
2214
|
+
if (!(filePath.includes("server") || filePath.includes(".server.") || filePath.endsWith("server.ts") || filePath.endsWith("server.tsx"))) return {};
|
|
2215
|
+
let hasProviderImport = false;
|
|
2216
|
+
const storeHookCalls = [];
|
|
2217
|
+
return {
|
|
2218
|
+
ImportDeclaration(node) {
|
|
2219
|
+
const info = extractImportInfo(node);
|
|
2220
|
+
if (!info) return;
|
|
2221
|
+
if (info.specifiers.some((s) => s.imported === "setStoreRegistryProvider" || s.imported === "runWithRequestContext")) hasProviderImport = true;
|
|
2222
|
+
},
|
|
2223
|
+
CallExpression(node) {
|
|
2224
|
+
const callee = node.callee;
|
|
2225
|
+
if (!callee || callee.type !== "Identifier") return;
|
|
2226
|
+
const name = callee.name;
|
|
2227
|
+
if (name.endsWith("Store") && name.startsWith("use")) storeHookCalls.push({
|
|
2228
|
+
name,
|
|
2229
|
+
span: getSpan(node)
|
|
2230
|
+
});
|
|
2231
|
+
},
|
|
2232
|
+
"Program:exit"() {
|
|
2233
|
+
if (hasProviderImport) return;
|
|
2234
|
+
for (const call of storeHookCalls) context.report({
|
|
2235
|
+
message: `\`${call.name}()\` in a server file without a store registry provider — use \`runWithRequestContext()\` or \`setStoreRegistryProvider()\` for SSR isolation.`,
|
|
2236
|
+
span: call.span
|
|
2237
|
+
});
|
|
2238
|
+
}
|
|
2239
|
+
};
|
|
2240
|
+
}
|
|
2241
|
+
};
|
|
2242
|
+
|
|
2243
|
+
//#endregion
|
|
2244
|
+
//#region src/rules/styling/no-dynamic-styled.ts
|
|
2245
|
+
const noDynamicStyled = {
|
|
2246
|
+
meta: {
|
|
2247
|
+
id: "pyreon/no-dynamic-styled",
|
|
2248
|
+
category: "styling",
|
|
2249
|
+
description: "Warn when styled() is called inside a function — it creates new CSS on every render.",
|
|
2250
|
+
severity: "warn",
|
|
2251
|
+
fixable: false
|
|
2252
|
+
},
|
|
2253
|
+
create(context) {
|
|
2254
|
+
let functionDepth = 0;
|
|
2255
|
+
return {
|
|
2256
|
+
FunctionDeclaration() {
|
|
2257
|
+
functionDepth++;
|
|
2258
|
+
},
|
|
2259
|
+
"FunctionDeclaration:exit"() {
|
|
2260
|
+
functionDepth--;
|
|
2261
|
+
},
|
|
2262
|
+
FunctionExpression() {
|
|
2263
|
+
functionDepth++;
|
|
2264
|
+
},
|
|
2265
|
+
"FunctionExpression:exit"() {
|
|
2266
|
+
functionDepth--;
|
|
2267
|
+
},
|
|
2268
|
+
ArrowFunctionExpression() {
|
|
2269
|
+
functionDepth++;
|
|
2270
|
+
},
|
|
2271
|
+
"ArrowFunctionExpression:exit"() {
|
|
2272
|
+
functionDepth--;
|
|
2273
|
+
},
|
|
2274
|
+
CallExpression(node) {
|
|
2275
|
+
if (functionDepth === 0) return;
|
|
2276
|
+
if (isCallTo(node, "styled")) context.report({
|
|
2277
|
+
message: "`styled()` inside a function — this creates new CSS rules on every render. Move `styled()` to module scope.",
|
|
2278
|
+
span: getSpan(node)
|
|
2279
|
+
});
|
|
2280
|
+
},
|
|
2281
|
+
TaggedTemplateExpression(node) {
|
|
2282
|
+
if (functionDepth === 0) return;
|
|
2283
|
+
const tag = node.tag;
|
|
2284
|
+
if (!tag) return;
|
|
2285
|
+
if (tag.type === "CallExpression" && isCallTo(tag, "styled")) context.report({
|
|
2286
|
+
message: "`styled()` tagged template inside a function — this creates new CSS rules on every render. Move to module scope.",
|
|
2287
|
+
span: getSpan(node)
|
|
2288
|
+
});
|
|
2289
|
+
}
|
|
2290
|
+
};
|
|
2291
|
+
}
|
|
2292
|
+
};
|
|
2293
|
+
|
|
2294
|
+
//#endregion
|
|
2295
|
+
//#region src/rules/styling/no-inline-style-object.ts
|
|
2296
|
+
const noInlineStyleObject = {
|
|
2297
|
+
meta: {
|
|
2298
|
+
id: "pyreon/no-inline-style-object",
|
|
2299
|
+
category: "styling",
|
|
2300
|
+
description: "Warn against inline style objects in JSX — prefer styled() or css``.",
|
|
2301
|
+
severity: "warn",
|
|
2302
|
+
fixable: false
|
|
2303
|
+
},
|
|
2304
|
+
create(context) {
|
|
2305
|
+
return { JSXAttribute(node) {
|
|
2306
|
+
if (node.name?.type !== "JSXIdentifier" || node.name.name !== "style") return;
|
|
2307
|
+
const value = node.value;
|
|
2308
|
+
if (!value || value.type !== "JSXExpressionContainer") return;
|
|
2309
|
+
if (value.expression?.type === "ObjectExpression") context.report({
|
|
2310
|
+
message: "Inline style object in JSX — consider using `styled()` or `css\\`...\\`` for better performance and caching.",
|
|
2311
|
+
span: getSpan(node)
|
|
2312
|
+
});
|
|
2313
|
+
} };
|
|
2314
|
+
}
|
|
2315
|
+
};
|
|
2316
|
+
|
|
2317
|
+
//#endregion
|
|
2318
|
+
//#region src/rules/styling/no-theme-outside-provider.ts
|
|
2319
|
+
const noThemeOutsideProvider = {
|
|
2320
|
+
meta: {
|
|
2321
|
+
id: "pyreon/no-theme-outside-provider",
|
|
2322
|
+
category: "styling",
|
|
2323
|
+
description: "Warn when useTheme() is used without PyreonUI or ThemeProvider in the same file.",
|
|
2324
|
+
severity: "warn",
|
|
2325
|
+
fixable: false
|
|
2326
|
+
},
|
|
2327
|
+
create(context) {
|
|
2328
|
+
let hasProviderImport = false;
|
|
2329
|
+
const themeCalls = [];
|
|
2330
|
+
return {
|
|
2331
|
+
ImportDeclaration(node) {
|
|
2332
|
+
const info = extractImportInfo(node);
|
|
2333
|
+
if (!info) return;
|
|
2334
|
+
if (info.specifiers.some((s) => s.imported === "PyreonUI" || s.imported === "ThemeProvider")) hasProviderImport = true;
|
|
2335
|
+
},
|
|
2336
|
+
CallExpression(node) {
|
|
2337
|
+
if (isCallTo(node, "useTheme")) themeCalls.push({ span: getSpan(node) });
|
|
2338
|
+
},
|
|
2339
|
+
"Program:exit"() {
|
|
2340
|
+
if (hasProviderImport) return;
|
|
2341
|
+
for (const call of themeCalls) context.report({
|
|
2342
|
+
message: "`useTheme()` without a `PyreonUI` or `ThemeProvider` import — the theme context may not be available.",
|
|
2343
|
+
span: call.span
|
|
2344
|
+
});
|
|
2345
|
+
}
|
|
2346
|
+
};
|
|
2347
|
+
}
|
|
2348
|
+
};
|
|
2349
|
+
|
|
2350
|
+
//#endregion
|
|
2351
|
+
//#region src/rules/styling/prefer-cx.ts
|
|
2352
|
+
const preferCx = {
|
|
2353
|
+
meta: {
|
|
2354
|
+
id: "pyreon/prefer-cx",
|
|
2355
|
+
category: "styling",
|
|
2356
|
+
description: "Suggest cx() for class composition instead of string concatenation or template literals.",
|
|
2357
|
+
severity: "info",
|
|
2358
|
+
fixable: false
|
|
2359
|
+
},
|
|
2360
|
+
create(context) {
|
|
2361
|
+
return { JSXAttribute(node) {
|
|
2362
|
+
if (node.name?.type !== "JSXIdentifier" || node.name.name !== "class") return;
|
|
2363
|
+
const value = node.value;
|
|
2364
|
+
if (!value || value.type !== "JSXExpressionContainer") return;
|
|
2365
|
+
const expr = value.expression;
|
|
2366
|
+
if (!expr) return;
|
|
2367
|
+
if (expr.type === "BinaryExpression" && expr.operator === "+") {
|
|
2368
|
+
context.report({
|
|
2369
|
+
message: "String concatenation in `class` attribute — use `cx()` for cleaner class composition.",
|
|
2370
|
+
span: getSpan(expr)
|
|
2371
|
+
});
|
|
2372
|
+
return;
|
|
2373
|
+
}
|
|
2374
|
+
if (expr.type === "TemplateLiteral" && expr.expressions?.length > 0) context.report({
|
|
2375
|
+
message: "Template literal in `class` attribute — use `cx()` for cleaner class composition.",
|
|
2376
|
+
span: getSpan(expr)
|
|
2377
|
+
});
|
|
2378
|
+
} };
|
|
2379
|
+
}
|
|
2380
|
+
};
|
|
2381
|
+
|
|
2382
|
+
//#endregion
|
|
2383
|
+
//#region src/rules/index.ts
|
|
2384
|
+
const allRules = [
|
|
2385
|
+
noBareSignalInJsx,
|
|
2386
|
+
noSignalInLoop,
|
|
2387
|
+
noNestedEffect,
|
|
2388
|
+
noPeekInTracked,
|
|
2389
|
+
noUnbatchedUpdates,
|
|
2390
|
+
preferComputed,
|
|
2391
|
+
noEffectAssignment,
|
|
2392
|
+
noSignalLeak,
|
|
2393
|
+
noMapInJsx,
|
|
2394
|
+
useByNotKey,
|
|
2395
|
+
noClassName,
|
|
2396
|
+
noHtmlFor,
|
|
2397
|
+
noOnChange,
|
|
2398
|
+
noTernaryConditional,
|
|
2399
|
+
noAndConditional,
|
|
2400
|
+
noIndexAsBy,
|
|
2401
|
+
noMissingForBy,
|
|
2402
|
+
noPropsDestructure,
|
|
2403
|
+
noChildrenAccess,
|
|
2404
|
+
noMissingCleanup,
|
|
2405
|
+
noMountInEffect,
|
|
2406
|
+
noEffectInMount,
|
|
2407
|
+
noDomInSetup,
|
|
2408
|
+
noLargeForWithoutBy,
|
|
2409
|
+
noEffectInFor,
|
|
2410
|
+
noEagerImport,
|
|
2411
|
+
preferShowOverDisplay,
|
|
2412
|
+
noWindowInSsr,
|
|
2413
|
+
noMismatchRisk,
|
|
2414
|
+
preferRequestContext,
|
|
2415
|
+
noCircularImport,
|
|
2416
|
+
noDeepImport,
|
|
2417
|
+
noCrossLayerImport,
|
|
2418
|
+
devGuardWarnings,
|
|
2419
|
+
noErrorWithoutPrefix,
|
|
2420
|
+
noStoreOutsideProvider,
|
|
2421
|
+
noMutateStoreState,
|
|
2422
|
+
noDuplicateStoreId,
|
|
2423
|
+
noUnregisteredField,
|
|
2424
|
+
noSubmitWithoutValidation,
|
|
2425
|
+
preferFieldArray,
|
|
2426
|
+
noInlineStyleObject,
|
|
2427
|
+
noDynamicStyled,
|
|
2428
|
+
preferCx,
|
|
2429
|
+
noThemeOutsideProvider,
|
|
2430
|
+
noRawAddEventListener,
|
|
2431
|
+
noRawSetInterval,
|
|
2432
|
+
noRawLocalStorage,
|
|
2433
|
+
toastA11y,
|
|
2434
|
+
dialogA11y,
|
|
2435
|
+
overlayA11y,
|
|
2436
|
+
noHrefNavigation,
|
|
2437
|
+
noImperativeNavigateInRender,
|
|
2438
|
+
noMissingFallback,
|
|
2439
|
+
preferUseIsActive
|
|
2440
|
+
];
|
|
2441
|
+
|
|
2442
|
+
//#endregion
|
|
2443
|
+
//#region src/config/presets.ts
|
|
2444
|
+
/** Build a config where every rule uses its default severity. */
|
|
2445
|
+
function buildRecommended() {
|
|
2446
|
+
const rules = {};
|
|
2447
|
+
for (const rule of allRules) rules[rule.meta.id] = rule.meta.severity;
|
|
2448
|
+
return { rules };
|
|
2449
|
+
}
|
|
2450
|
+
/** Build a config where every warn is promoted to error. */
|
|
2451
|
+
function buildStrict() {
|
|
2452
|
+
const base = buildRecommended();
|
|
2453
|
+
const rules = {};
|
|
2454
|
+
for (const [id, sev] of Object.entries(base.rules)) rules[id] = sev === "warn" ? "error" : sev;
|
|
2455
|
+
return { rules };
|
|
2456
|
+
}
|
|
2457
|
+
/** Build app config — recommended but disable library-only rules. */
|
|
2458
|
+
function buildApp() {
|
|
2459
|
+
return { rules: {
|
|
2460
|
+
...buildRecommended().rules,
|
|
2461
|
+
"pyreon/dev-guard-warnings": "off",
|
|
2462
|
+
"pyreon/no-error-without-prefix": "off",
|
|
2463
|
+
"pyreon/no-circular-import": "off",
|
|
2464
|
+
"pyreon/no-cross-layer-import": "off"
|
|
2465
|
+
} };
|
|
2466
|
+
}
|
|
2467
|
+
/** Build lib config — strict + all architecture rules as error. */
|
|
2468
|
+
function buildLib() {
|
|
2469
|
+
return { rules: {
|
|
2470
|
+
...buildStrict().rules,
|
|
2471
|
+
"pyreon/no-circular-import": "error",
|
|
2472
|
+
"pyreon/no-cross-layer-import": "error",
|
|
2473
|
+
"pyreon/dev-guard-warnings": "error",
|
|
2474
|
+
"pyreon/no-error-without-prefix": "error"
|
|
2475
|
+
} };
|
|
2476
|
+
}
|
|
2477
|
+
const presetBuilders = {
|
|
2478
|
+
recommended: buildRecommended,
|
|
2479
|
+
strict: buildStrict,
|
|
2480
|
+
app: buildApp,
|
|
2481
|
+
lib: buildLib
|
|
2482
|
+
};
|
|
2483
|
+
function getPreset(name) {
|
|
2484
|
+
return presetBuilders[name]();
|
|
2485
|
+
}
|
|
2486
|
+
|
|
2487
|
+
//#endregion
|
|
2488
|
+
//#region src/utils/source.ts
|
|
2489
|
+
/**
|
|
2490
|
+
* Fast offset→line/column conversion using binary search over precomputed line starts.
|
|
2491
|
+
*/
|
|
2492
|
+
var LineIndex = class {
|
|
2493
|
+
lineStarts;
|
|
2494
|
+
constructor(sourceText) {
|
|
2495
|
+
this.lineStarts = [0];
|
|
2496
|
+
for (let i = 0; i < sourceText.length; i++) if (sourceText[i] === "\n") this.lineStarts.push(i + 1);
|
|
2497
|
+
}
|
|
2498
|
+
/** Convert a byte offset to a 1-based line and 0-based column. */
|
|
2499
|
+
locate(offset) {
|
|
2500
|
+
let lo = 0;
|
|
2501
|
+
let hi = this.lineStarts.length - 1;
|
|
2502
|
+
while (lo <= hi) {
|
|
2503
|
+
const mid = lo + hi >>> 1;
|
|
2504
|
+
if (this.lineStarts[mid] <= offset) lo = mid + 1;
|
|
2505
|
+
else hi = mid - 1;
|
|
2506
|
+
}
|
|
2507
|
+
const line = lo;
|
|
2508
|
+
return {
|
|
2509
|
+
line,
|
|
2510
|
+
column: offset - this.lineStarts[line - 1]
|
|
2511
|
+
};
|
|
2512
|
+
}
|
|
2513
|
+
};
|
|
2514
|
+
|
|
2515
|
+
//#endregion
|
|
2516
|
+
//#region src/utils/index.ts
|
|
2517
|
+
/** Supported JS/TS file extensions for linting. */
|
|
2518
|
+
const JS_EXTENSIONS = new Set([
|
|
2519
|
+
".ts",
|
|
2520
|
+
".tsx",
|
|
2521
|
+
".js",
|
|
2522
|
+
".jsx",
|
|
2523
|
+
".mts",
|
|
2524
|
+
".mjs"
|
|
2525
|
+
]);
|
|
2526
|
+
/** Check if a file path has a supported JS/TS extension. */
|
|
2527
|
+
function hasJsExtension(filePath) {
|
|
2528
|
+
const ext = filePath.slice(filePath.lastIndexOf("."));
|
|
2529
|
+
return JS_EXTENSIONS.has(ext);
|
|
2530
|
+
}
|
|
2531
|
+
|
|
2532
|
+
//#endregion
|
|
2533
|
+
//#region src/runner.ts
|
|
2534
|
+
function getExtension(filePath) {
|
|
2535
|
+
const lastDot = filePath.lastIndexOf(".");
|
|
2536
|
+
return lastDot === -1 ? "" : filePath.slice(lastDot);
|
|
2537
|
+
}
|
|
2538
|
+
function getLang(ext) {
|
|
2539
|
+
if (ext === ".tsx" || ext === ".jsx") return "tsx";
|
|
2540
|
+
if (ext === ".ts" || ext === ".mts") return "ts";
|
|
2541
|
+
return "js";
|
|
2542
|
+
}
|
|
2543
|
+
function createRuleContext(rule, severity, diagnostics, lineIndex, sourceText, filePath) {
|
|
2544
|
+
return {
|
|
2545
|
+
report(partial) {
|
|
2546
|
+
diagnostics.push({
|
|
2547
|
+
ruleId: rule.meta.id,
|
|
2548
|
+
severity,
|
|
2549
|
+
message: partial.message,
|
|
2550
|
+
span: partial.span,
|
|
2551
|
+
loc: lineIndex.locate(partial.span.start),
|
|
2552
|
+
fix: partial.fix
|
|
2553
|
+
});
|
|
2554
|
+
},
|
|
2555
|
+
getSourceText() {
|
|
2556
|
+
return sourceText;
|
|
2557
|
+
},
|
|
2558
|
+
getFilePath() {
|
|
2559
|
+
return filePath;
|
|
2560
|
+
}
|
|
2561
|
+
};
|
|
2562
|
+
}
|
|
2563
|
+
function mergeCallbacks(allCallbacks) {
|
|
2564
|
+
const callbacksByKey = {};
|
|
2565
|
+
for (const callbacks of allCallbacks) for (const [key, fn] of Object.entries(callbacks)) {
|
|
2566
|
+
const existing = callbacksByKey[key];
|
|
2567
|
+
if (existing) existing.push(fn);
|
|
2568
|
+
else callbacksByKey[key] = [fn];
|
|
2569
|
+
}
|
|
2570
|
+
const merged = {};
|
|
2571
|
+
for (const [key, fns] of Object.entries(callbacksByKey)) {
|
|
2572
|
+
const first = fns[0];
|
|
2573
|
+
if (fns.length === 1 && first) merged[key] = first;
|
|
2574
|
+
else merged[key] = (node) => {
|
|
2575
|
+
for (const fn of fns) fn(node);
|
|
2576
|
+
};
|
|
2577
|
+
}
|
|
2578
|
+
return merged;
|
|
2579
|
+
}
|
|
2580
|
+
/**
|
|
2581
|
+
* Lint a single file and return diagnostics.
|
|
2582
|
+
*
|
|
2583
|
+
* @example
|
|
2584
|
+
* ```ts
|
|
2585
|
+
* const result = lintFile("app.tsx", source, allRules, getPreset("recommended"))
|
|
2586
|
+
* for (const d of result.diagnostics) console.log(d.message)
|
|
2587
|
+
* ```
|
|
2588
|
+
*/
|
|
2589
|
+
function lintFile(filePath, sourceText, rules, config, cache) {
|
|
2590
|
+
const ext = getExtension(filePath);
|
|
2591
|
+
if (!JS_EXTENSIONS.has(ext)) return {
|
|
2592
|
+
filePath,
|
|
2593
|
+
diagnostics: []
|
|
2594
|
+
};
|
|
2595
|
+
let lineIndex;
|
|
2596
|
+
let program;
|
|
2597
|
+
const cached = cache?.get(sourceText);
|
|
2598
|
+
if (cached) {
|
|
2599
|
+
lineIndex = cached.lineIndex;
|
|
2600
|
+
program = cached.program;
|
|
2601
|
+
} else {
|
|
2602
|
+
lineIndex = new LineIndex(sourceText);
|
|
2603
|
+
try {
|
|
2604
|
+
program = parseSync(filePath, sourceText, {
|
|
2605
|
+
sourceType: "module",
|
|
2606
|
+
lang: getLang(ext)
|
|
2607
|
+
}).program;
|
|
2608
|
+
} catch {
|
|
2609
|
+
return {
|
|
2610
|
+
filePath,
|
|
2611
|
+
diagnostics: []
|
|
2612
|
+
};
|
|
2613
|
+
}
|
|
2614
|
+
cache?.set(sourceText, {
|
|
2615
|
+
program,
|
|
2616
|
+
lineIndex
|
|
2617
|
+
});
|
|
2618
|
+
}
|
|
2619
|
+
const diagnostics = [];
|
|
2620
|
+
const allCallbacks = [];
|
|
2621
|
+
for (const rule of rules) {
|
|
2622
|
+
const severity = config.rules[rule.meta.id];
|
|
2623
|
+
if (severity === void 0 || severity === "off") continue;
|
|
2624
|
+
const ctx = createRuleContext(rule, severity, diagnostics, lineIndex, sourceText, filePath);
|
|
2625
|
+
allCallbacks.push(rule.create(ctx));
|
|
2626
|
+
}
|
|
2627
|
+
new Visitor(mergeCallbacks(allCallbacks)).visit(program);
|
|
2628
|
+
diagnostics.sort((a, b) => a.span.start - b.span.start);
|
|
2629
|
+
return {
|
|
2630
|
+
filePath,
|
|
2631
|
+
diagnostics
|
|
2632
|
+
};
|
|
2633
|
+
}
|
|
2634
|
+
/**
|
|
2635
|
+
* Apply all auto-fixes to a source text.
|
|
2636
|
+
* Fixes are applied in reverse order to maintain correct offsets.
|
|
2637
|
+
*/
|
|
2638
|
+
function applyFixes(sourceText, diagnostics) {
|
|
2639
|
+
const fixable = diagnostics.filter((d) => d.fix !== void 0);
|
|
2640
|
+
if (fixable.length === 0) return sourceText;
|
|
2641
|
+
const sorted = [...fixable].sort((a, b) => {
|
|
2642
|
+
const aFix = a.fix;
|
|
2643
|
+
const bFix = b.fix;
|
|
2644
|
+
if (!aFix || !bFix) return 0;
|
|
2645
|
+
return bFix.span.start - aFix.span.start;
|
|
2646
|
+
});
|
|
2647
|
+
let result = sourceText;
|
|
2648
|
+
for (const diag of sorted) {
|
|
2649
|
+
const fix = diag.fix;
|
|
2650
|
+
if (!fix) continue;
|
|
2651
|
+
result = result.slice(0, fix.span.start) + fix.replacement + result.slice(fix.span.end);
|
|
2652
|
+
}
|
|
2653
|
+
return result;
|
|
2654
|
+
}
|
|
2655
|
+
|
|
2656
|
+
//#endregion
|
|
2657
|
+
//#region src/lint.ts
|
|
2658
|
+
function isHiddenOrVendor(entry) {
|
|
2659
|
+
return entry.startsWith(".") || entry === "node_modules" || entry === "lib" || entry === "dist";
|
|
2660
|
+
}
|
|
2661
|
+
function matchesPatterns(filePath, include, exclude) {
|
|
2662
|
+
if (exclude) {
|
|
2663
|
+
for (const pattern of exclude) if (filePath.includes(pattern)) return false;
|
|
2664
|
+
}
|
|
2665
|
+
if (include && include.length > 0) {
|
|
2666
|
+
for (const pattern of include) if (filePath.includes(pattern)) return true;
|
|
2667
|
+
return false;
|
|
2668
|
+
}
|
|
2669
|
+
return true;
|
|
2670
|
+
}
|
|
2671
|
+
function walkDirectory(dir, files, isIgnored, include, exclude) {
|
|
2672
|
+
let entries;
|
|
2673
|
+
try {
|
|
2674
|
+
entries = readdirSync(dir);
|
|
2675
|
+
} catch {
|
|
2676
|
+
return;
|
|
2677
|
+
}
|
|
2678
|
+
for (const entry of entries) {
|
|
2679
|
+
if (isHiddenOrVendor(entry)) continue;
|
|
2680
|
+
const full = join(dir, entry);
|
|
2681
|
+
if (isIgnored(full)) continue;
|
|
2682
|
+
processEntry(full, files, isIgnored, include, exclude);
|
|
2683
|
+
}
|
|
2684
|
+
}
|
|
2685
|
+
function processEntry(full, files, isIgnored, include, exclude) {
|
|
2686
|
+
let stat;
|
|
2687
|
+
try {
|
|
2688
|
+
stat = statSync(full);
|
|
2689
|
+
} catch {
|
|
2690
|
+
return;
|
|
2691
|
+
}
|
|
2692
|
+
if (stat.isDirectory()) walkDirectory(full, files, isIgnored, include, exclude);
|
|
2693
|
+
else if (stat.isFile() && hasJsExtension(full) && matchesPatterns(full, include, exclude)) files.push(full);
|
|
2694
|
+
}
|
|
2695
|
+
function collectFiles(dir, isIgnored, include, exclude) {
|
|
2696
|
+
const files = [];
|
|
2697
|
+
walkDirectory(dir, files, isIgnored, include, exclude);
|
|
2698
|
+
return files;
|
|
2699
|
+
}
|
|
2700
|
+
function buildConfig(options) {
|
|
2701
|
+
const cwd = resolve(".");
|
|
2702
|
+
const fileConfig = options.config ? loadConfigFromPath(options.config) : loadConfig(cwd);
|
|
2703
|
+
const config = getPreset(options.preset ?? fileConfig?.preset ?? "recommended");
|
|
2704
|
+
if (fileConfig?.rules) for (const [id, severity] of Object.entries(fileConfig.rules)) config.rules[id] = severity;
|
|
2705
|
+
if (options.ruleOverrides) for (const [id, severity] of Object.entries(options.ruleOverrides)) config.rules[id] = severity;
|
|
2706
|
+
return {
|
|
2707
|
+
config,
|
|
2708
|
+
include: fileConfig?.include,
|
|
2709
|
+
exclude: fileConfig?.exclude,
|
|
2710
|
+
isIgnored: createIgnoreFilter(cwd, options.ignore)
|
|
2711
|
+
};
|
|
2712
|
+
}
|
|
2713
|
+
function gatherFiles(paths, isIgnored, include, exclude) {
|
|
2714
|
+
const files = [];
|
|
2715
|
+
for (const p of paths) {
|
|
2716
|
+
const resolved = resolve(p);
|
|
2717
|
+
let stat;
|
|
2718
|
+
try {
|
|
2719
|
+
stat = statSync(resolved);
|
|
2720
|
+
} catch {
|
|
2721
|
+
continue;
|
|
2722
|
+
}
|
|
2723
|
+
if (stat.isDirectory()) files.push(...collectFiles(resolved, isIgnored, include, exclude));
|
|
2724
|
+
else if (stat.isFile() && !isIgnored(resolved)) files.push(resolved);
|
|
2725
|
+
}
|
|
2726
|
+
return files;
|
|
2727
|
+
}
|
|
2728
|
+
function applyFixesToFile(fileResult, source) {
|
|
2729
|
+
if (fileResult.diagnostics.filter((d) => d.fix).length === 0) return;
|
|
2730
|
+
const fixed = applyFixes(source, fileResult.diagnostics);
|
|
2731
|
+
writeFileSync(fileResult.filePath, fixed, "utf-8");
|
|
2732
|
+
fileResult.fixedSource = fixed;
|
|
2733
|
+
fileResult.diagnostics = fileResult.diagnostics.filter((d) => !d.fix);
|
|
2734
|
+
}
|
|
2735
|
+
function countDiagnostics(fileResult, results) {
|
|
2736
|
+
for (const d of fileResult.diagnostics) if (d.severity === "error") results.totalErrors++;
|
|
2737
|
+
else if (d.severity === "warn") results.totalWarnings++;
|
|
2738
|
+
else if (d.severity === "info") results.totalInfos++;
|
|
2739
|
+
}
|
|
2740
|
+
/**
|
|
2741
|
+
* Lint files and return results.
|
|
2742
|
+
*
|
|
2743
|
+
* @example
|
|
2744
|
+
* ```ts
|
|
2745
|
+
* import { lint } from "@pyreon/lint"
|
|
2746
|
+
*
|
|
2747
|
+
* const result = lint({ paths: ["src/"], preset: "recommended" })
|
|
2748
|
+
* console.log(result.totalErrors) // 0
|
|
2749
|
+
* ```
|
|
2750
|
+
*/
|
|
2751
|
+
function lint(options) {
|
|
2752
|
+
const { config, include, exclude, isIgnored } = buildConfig(options);
|
|
2753
|
+
const cache = new AstCache();
|
|
2754
|
+
const files = gatherFiles(options.paths, isIgnored, include, exclude);
|
|
2755
|
+
const results = {
|
|
2756
|
+
files: [],
|
|
2757
|
+
totalErrors: 0,
|
|
2758
|
+
totalWarnings: 0,
|
|
2759
|
+
totalInfos: 0
|
|
2760
|
+
};
|
|
2761
|
+
for (const filePath of files) {
|
|
2762
|
+
let source;
|
|
2763
|
+
try {
|
|
2764
|
+
source = readFileSync(filePath, "utf-8");
|
|
2765
|
+
} catch {
|
|
2766
|
+
continue;
|
|
2767
|
+
}
|
|
2768
|
+
const fileResult = lintFile(filePath, source, allRules, config, cache);
|
|
2769
|
+
if (options.fix) applyFixesToFile(fileResult, source);
|
|
2770
|
+
if (options.quiet) fileResult.diagnostics = fileResult.diagnostics.filter((d) => d.severity === "error");
|
|
2771
|
+
countDiagnostics(fileResult, results);
|
|
2772
|
+
results.files.push(fileResult);
|
|
2773
|
+
}
|
|
2774
|
+
return results;
|
|
2775
|
+
}
|
|
2776
|
+
/**
|
|
2777
|
+
* List all available rules with their metadata.
|
|
2778
|
+
*
|
|
2779
|
+
* @example
|
|
2780
|
+
* ```ts
|
|
2781
|
+
* import { listRules } from "@pyreon/lint"
|
|
2782
|
+
*
|
|
2783
|
+
* for (const rule of listRules()) {
|
|
2784
|
+
* console.log(`${rule.id} (${rule.severity}): ${rule.description}`)
|
|
2785
|
+
* }
|
|
2786
|
+
* ```
|
|
2787
|
+
*/
|
|
2788
|
+
function listRules() {
|
|
2789
|
+
return allRules.map((r) => r.meta);
|
|
2790
|
+
}
|
|
2791
|
+
|
|
2792
|
+
//#endregion
|
|
2793
|
+
//#region src/reporter.ts
|
|
2794
|
+
const BOLD = "\x1B[1m";
|
|
2795
|
+
const RED = "\x1B[31m";
|
|
2796
|
+
const YELLOW = "\x1B[33m";
|
|
2797
|
+
const BLUE = "\x1B[34m";
|
|
2798
|
+
const DIM = "\x1B[2m";
|
|
2799
|
+
const RESET = "\x1B[0m";
|
|
2800
|
+
const SEVERITY_SYMBOL = {
|
|
2801
|
+
error: `${RED}\u2716${RESET}`,
|
|
2802
|
+
warn: `${YELLOW}\u26A0${RESET}`,
|
|
2803
|
+
info: `${BLUE}\u2139${RESET}`,
|
|
2804
|
+
off: ""
|
|
2805
|
+
};
|
|
2806
|
+
const SEVERITY_LABEL = {
|
|
2807
|
+
error: `${RED}error${RESET}`,
|
|
2808
|
+
warn: `${YELLOW}warning${RESET}`,
|
|
2809
|
+
info: `${BLUE}info${RESET}`,
|
|
2810
|
+
off: ""
|
|
2811
|
+
};
|
|
2812
|
+
/**
|
|
2813
|
+
* Format results as human-readable colored text.
|
|
2814
|
+
*/
|
|
2815
|
+
function formatText(result) {
|
|
2816
|
+
const lines = [];
|
|
2817
|
+
for (const file of result.files) {
|
|
2818
|
+
if (file.diagnostics.length === 0) continue;
|
|
2819
|
+
lines.push("");
|
|
2820
|
+
lines.push(`${BOLD}${file.filePath}${RESET}`);
|
|
2821
|
+
for (const d of file.diagnostics) {
|
|
2822
|
+
const loc = `${DIM}${d.loc.line}:${d.loc.column}${RESET}`;
|
|
2823
|
+
const severity = SEVERITY_LABEL[d.severity];
|
|
2824
|
+
const ruleId = `${DIM}${d.ruleId}${RESET}`;
|
|
2825
|
+
lines.push(` ${loc} ${severity} ${d.message} ${ruleId}`);
|
|
2826
|
+
}
|
|
2827
|
+
}
|
|
2828
|
+
if (result.totalErrors + result.totalWarnings + result.totalInfos > 0) {
|
|
2829
|
+
lines.push("");
|
|
2830
|
+
const parts = [];
|
|
2831
|
+
if (result.totalErrors > 0) parts.push(`${RED}${result.totalErrors} error${result.totalErrors === 1 ? "" : "s"}${RESET}`);
|
|
2832
|
+
if (result.totalWarnings > 0) parts.push(`${YELLOW}${result.totalWarnings} warning${result.totalWarnings === 1 ? "" : "s"}${RESET}`);
|
|
2833
|
+
if (result.totalInfos > 0) parts.push(`${BLUE}${result.totalInfos} info${RESET}`);
|
|
2834
|
+
lines.push(`${SEVERITY_SYMBOL.error} ${parts.join(", ")}`);
|
|
2835
|
+
lines.push("");
|
|
2836
|
+
}
|
|
2837
|
+
return lines.join("\n");
|
|
2838
|
+
}
|
|
2839
|
+
/**
|
|
2840
|
+
* Format results as JSON.
|
|
2841
|
+
*/
|
|
2842
|
+
function formatJSON(result) {
|
|
2843
|
+
return JSON.stringify(result, null, 2);
|
|
2844
|
+
}
|
|
2845
|
+
/**
|
|
2846
|
+
* Format results as compact single-line-per-diagnostic output.
|
|
2847
|
+
*/
|
|
2848
|
+
function formatCompact(result) {
|
|
2849
|
+
const lines = [];
|
|
2850
|
+
for (const file of result.files) for (const d of file.diagnostics) lines.push(`${file.filePath}:${d.loc.line}:${d.loc.column}: ${d.severity} [${d.ruleId}] ${d.message}`);
|
|
2851
|
+
return lines.join("\n");
|
|
2852
|
+
}
|
|
2853
|
+
|
|
2854
|
+
//#endregion
|
|
2855
|
+
//#region src/watcher.ts
|
|
2856
|
+
function formatOutput(result, format) {
|
|
2857
|
+
if (format === "json") return formatJSON(result);
|
|
2858
|
+
if (format === "compact") return formatCompact(result);
|
|
2859
|
+
return formatText(result);
|
|
2860
|
+
}
|
|
2861
|
+
/**
|
|
2862
|
+
* Watch directories and re-lint changed files.
|
|
2863
|
+
*
|
|
2864
|
+
* Uses `fs.watch` (recursive) with 100ms debounce.
|
|
2865
|
+
* Caches ASTs for unchanged files.
|
|
2866
|
+
*
|
|
2867
|
+
* @example
|
|
2868
|
+
* ```ts
|
|
2869
|
+
* import { watchAndLint } from "@pyreon/lint"
|
|
2870
|
+
*
|
|
2871
|
+
* watchAndLint({ paths: ["src/"], preset: "recommended", format: "text" })
|
|
2872
|
+
* ```
|
|
2873
|
+
*/
|
|
2874
|
+
function watchAndLint(options) {
|
|
2875
|
+
const cache = new AstCache();
|
|
2876
|
+
const config = getPreset(options.preset ?? "recommended");
|
|
2877
|
+
applyOverrides(config, options.ruleOverrides);
|
|
2878
|
+
const isIgnored = createIgnoreFilter(resolve("."), options.ignore);
|
|
2879
|
+
const pending = /* @__PURE__ */ new Map();
|
|
2880
|
+
console.log(`\x1b[2m[pyreon-lint] Watching for changes...\x1b[0m\n`);
|
|
2881
|
+
for (const p of options.paths) {
|
|
2882
|
+
const dir = resolve(p);
|
|
2883
|
+
try {
|
|
2884
|
+
watch(dir, { recursive: true }, (_event, filename) => {
|
|
2885
|
+
if (!filename) return;
|
|
2886
|
+
const filePath = resolve(dir, filename);
|
|
2887
|
+
if (!hasJsExtension(filePath) || isIgnored(filePath)) return;
|
|
2888
|
+
const existing = pending.get(filePath);
|
|
2889
|
+
if (existing) clearTimeout(existing);
|
|
2890
|
+
pending.set(filePath, setTimeout(() => {
|
|
2891
|
+
pending.delete(filePath);
|
|
2892
|
+
relintFile(filePath, config, cache, options.format);
|
|
2893
|
+
}, 100));
|
|
2894
|
+
});
|
|
2895
|
+
} catch {
|
|
2896
|
+
console.error(`[pyreon-lint] Could not watch: ${dir}`);
|
|
2897
|
+
}
|
|
2898
|
+
}
|
|
2899
|
+
}
|
|
2900
|
+
function applyOverrides(config, overrides) {
|
|
2901
|
+
if (!overrides) return;
|
|
2902
|
+
for (const [id, severity] of Object.entries(overrides)) config.rules[id] = severity;
|
|
2903
|
+
}
|
|
2904
|
+
function relintFile(filePath, config, cache, format) {
|
|
2905
|
+
let source;
|
|
2906
|
+
try {
|
|
2907
|
+
source = readFileSync(filePath, "utf-8");
|
|
2908
|
+
} catch {
|
|
2909
|
+
return;
|
|
2910
|
+
}
|
|
2911
|
+
const fileResult = lintFile(filePath, source, allRules, config, cache);
|
|
2912
|
+
if (fileResult.diagnostics.length === 0) return;
|
|
2913
|
+
const result = {
|
|
2914
|
+
files: [fileResult],
|
|
2915
|
+
totalErrors: 0,
|
|
2916
|
+
totalWarnings: 0,
|
|
2917
|
+
totalInfos: 0
|
|
2918
|
+
};
|
|
2919
|
+
for (const d of fileResult.diagnostics) if (d.severity === "error") result.totalErrors++;
|
|
2920
|
+
else if (d.severity === "warn") result.totalWarnings++;
|
|
2921
|
+
else if (d.severity === "info") result.totalInfos++;
|
|
2922
|
+
process.stdout.write("\x1B[2J\x1B[H");
|
|
2923
|
+
console.log(formatOutput(result, format));
|
|
2924
|
+
}
|
|
2925
|
+
|
|
2926
|
+
//#endregion
|
|
2927
|
+
//#region src/cli.ts
|
|
2928
|
+
const VERSION = "0.11.4";
|
|
2929
|
+
function printUsage() {
|
|
2930
|
+
console.log(`
|
|
2931
|
+
pyreon-lint [options] [path...]
|
|
2932
|
+
|
|
2933
|
+
Options:
|
|
2934
|
+
--preset <name> Preset: recommended (default), strict, app, lib
|
|
2935
|
+
--fix Auto-fix fixable issues
|
|
2936
|
+
--format <fmt> Output: text (default), json, compact
|
|
2937
|
+
--quiet Only show errors
|
|
2938
|
+
--list List all available rules
|
|
2939
|
+
--rule <id>=<sev> Override rule severity
|
|
2940
|
+
--config <path> Config file path
|
|
2941
|
+
--ignore <path> Ignore file path
|
|
2942
|
+
--watch Watch mode — re-lint on file changes
|
|
2943
|
+
--help, -h Show this help
|
|
2944
|
+
--version, -v Show version
|
|
2945
|
+
`);
|
|
2946
|
+
}
|
|
2947
|
+
function printList() {
|
|
2948
|
+
const rules = listRules();
|
|
2949
|
+
const maxId = Math.max(...rules.map((r) => r.id.length));
|
|
2950
|
+
const maxCat = Math.max(...rules.map((r) => r.category.length));
|
|
2951
|
+
for (const rule of rules) {
|
|
2952
|
+
const fixLabel = rule.fixable ? " [fixable]" : "";
|
|
2953
|
+
const id = rule.id.padEnd(maxId);
|
|
2954
|
+
const cat = rule.category.padEnd(maxCat);
|
|
2955
|
+
const sev = rule.severity.padEnd(5);
|
|
2956
|
+
console.log(` ${id} ${cat} ${sev} ${rule.description}${fixLabel}`);
|
|
2957
|
+
}
|
|
2958
|
+
console.log(`\n ${rules.length} rules total`);
|
|
2959
|
+
}
|
|
2960
|
+
const BOOLEAN_FLAGS = {
|
|
2961
|
+
"--help": "showHelp",
|
|
2962
|
+
"-h": "showHelp",
|
|
2963
|
+
"--version": "showVersion",
|
|
2964
|
+
"-v": "showVersion",
|
|
2965
|
+
"--list": "showList",
|
|
2966
|
+
"--fix": "fix",
|
|
2967
|
+
"--quiet": "quiet",
|
|
2968
|
+
"--watch": "watchMode"
|
|
2969
|
+
};
|
|
2970
|
+
function parseArgs(argv) {
|
|
2971
|
+
const result = {
|
|
2972
|
+
preset: "recommended",
|
|
2973
|
+
fix: false,
|
|
2974
|
+
format: "text",
|
|
2975
|
+
quiet: false,
|
|
2976
|
+
showList: false,
|
|
2977
|
+
showHelp: false,
|
|
2978
|
+
showVersion: false,
|
|
2979
|
+
watchMode: false,
|
|
2980
|
+
configPath: void 0,
|
|
2981
|
+
ignorePath: void 0,
|
|
2982
|
+
ruleOverrides: {},
|
|
2983
|
+
paths: []
|
|
2984
|
+
};
|
|
2985
|
+
for (let i = 0; i < argv.length; i++) {
|
|
2986
|
+
const arg = argv[i];
|
|
2987
|
+
const boolKey = BOOLEAN_FLAGS[arg];
|
|
2988
|
+
if (boolKey) {
|
|
2989
|
+
result[boolKey] = true;
|
|
2990
|
+
continue;
|
|
2991
|
+
}
|
|
2992
|
+
const consumed = parseValueFlag(arg, argv[i + 1], result);
|
|
2993
|
+
i += consumed;
|
|
2994
|
+
}
|
|
2995
|
+
return result;
|
|
2996
|
+
}
|
|
2997
|
+
/** Returns number of extra args consumed (0 or 1). */
|
|
2998
|
+
function parseValueFlag(arg, nextArg, result) {
|
|
2999
|
+
if (arg === "--preset") {
|
|
3000
|
+
result.preset = nextArg ?? "recommended";
|
|
3001
|
+
return 1;
|
|
3002
|
+
}
|
|
3003
|
+
if (arg === "--format") {
|
|
3004
|
+
result.format = nextArg ?? "text";
|
|
3005
|
+
return 1;
|
|
3006
|
+
}
|
|
3007
|
+
if (arg === "--config") {
|
|
3008
|
+
result.configPath = nextArg;
|
|
3009
|
+
return 1;
|
|
3010
|
+
}
|
|
3011
|
+
if (arg === "--ignore") {
|
|
3012
|
+
result.ignorePath = nextArg;
|
|
3013
|
+
return 1;
|
|
3014
|
+
}
|
|
3015
|
+
if (arg === "--rule") {
|
|
3016
|
+
parseRuleOverride(nextArg, result.ruleOverrides);
|
|
3017
|
+
return 1;
|
|
3018
|
+
}
|
|
3019
|
+
if (arg) result.paths.push(arg);
|
|
3020
|
+
return 0;
|
|
3021
|
+
}
|
|
3022
|
+
function parseRuleOverride(val, overrides) {
|
|
3023
|
+
if (!val) return;
|
|
3024
|
+
const eqIdx = val.lastIndexOf("=");
|
|
3025
|
+
if (eqIdx === -1) return;
|
|
3026
|
+
const ruleId = val.slice(0, eqIdx);
|
|
3027
|
+
overrides[ruleId] = val.slice(eqIdx + 1);
|
|
3028
|
+
}
|
|
3029
|
+
function main() {
|
|
3030
|
+
const args = parseArgs(process.argv.slice(2));
|
|
3031
|
+
if (args.showHelp) {
|
|
3032
|
+
printUsage();
|
|
3033
|
+
process.exit(0);
|
|
3034
|
+
}
|
|
3035
|
+
if (args.showVersion) {
|
|
3036
|
+
console.log(`pyreon-lint v${VERSION}`);
|
|
3037
|
+
process.exit(0);
|
|
3038
|
+
}
|
|
3039
|
+
if (args.showList) {
|
|
3040
|
+
printList();
|
|
3041
|
+
process.exit(0);
|
|
3042
|
+
}
|
|
3043
|
+
if (args.paths.length === 0) args.paths.push(".");
|
|
3044
|
+
if (args.watchMode) {
|
|
3045
|
+
watchAndLint({
|
|
3046
|
+
paths: args.paths,
|
|
3047
|
+
preset: args.preset,
|
|
3048
|
+
fix: args.fix,
|
|
3049
|
+
quiet: args.quiet,
|
|
3050
|
+
ruleOverrides: args.ruleOverrides,
|
|
3051
|
+
config: args.configPath,
|
|
3052
|
+
ignore: args.ignorePath,
|
|
3053
|
+
format: args.format
|
|
3054
|
+
});
|
|
3055
|
+
return;
|
|
3056
|
+
}
|
|
3057
|
+
const result = lint({
|
|
3058
|
+
paths: args.paths,
|
|
3059
|
+
preset: args.preset,
|
|
3060
|
+
fix: args.fix,
|
|
3061
|
+
quiet: args.quiet,
|
|
3062
|
+
ruleOverrides: args.ruleOverrides,
|
|
3063
|
+
config: args.configPath,
|
|
3064
|
+
ignore: args.ignorePath
|
|
3065
|
+
});
|
|
3066
|
+
if (args.format === "json") console.log(formatJSON(result));
|
|
3067
|
+
else if (args.format === "compact") console.log(formatCompact(result));
|
|
3068
|
+
else {
|
|
3069
|
+
const output = formatText(result);
|
|
3070
|
+
if (output) console.log(output);
|
|
3071
|
+
}
|
|
3072
|
+
if (result.totalErrors > 0) process.exit(1);
|
|
3073
|
+
}
|
|
3074
|
+
main();
|
|
3075
|
+
|
|
3076
|
+
//#endregion
|
|
3077
|
+
//# sourceMappingURL=cli.js.map
|