@pyreon/lint 0.12.13 → 0.12.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -2
- package/lib/analysis/cli.js.html +1 -1
- package/lib/analysis/index.js.html +1 -1
- package/lib/cli.js +960 -162
- package/lib/cli.js.map +1 -1
- package/lib/index.js +935 -161
- package/lib/index.js.map +1 -1
- package/lib/types/index.d.ts +96 -23
- package/lib/types/index.d.ts.map +1 -1
- package/package.json +2 -1
- package/schema/pyreonlintrc.schema.json +64 -0
- package/src/cli.ts +44 -2
- package/src/config/presets.ts +13 -1
- package/src/index.ts +7 -0
- package/src/lint.ts +37 -6
- package/src/lsp/index.ts +15 -2
- package/src/rules/architecture/dev-guard-warnings.ts +172 -17
- package/src/rules/architecture/no-circular-import.ts +7 -0
- package/src/rules/architecture/no-process-dev-gate.ts +18 -45
- package/src/rules/architecture/require-browser-smoke-test.ts +227 -0
- package/src/rules/form/no-submit-without-validation.ts +9 -0
- package/src/rules/form/no-unregistered-field.ts +9 -0
- package/src/rules/hooks/no-raw-addeventlistener.ts +7 -0
- package/src/rules/hooks/no-raw-localstorage.ts +12 -1
- package/src/rules/hooks/no-raw-setinterval.ts +14 -0
- package/src/rules/index.ts +4 -1
- package/src/rules/jsx/no-props-destructure.ts +20 -6
- package/src/rules/lifecycle/no-dom-in-setup.ts +67 -7
- package/src/rules/reactivity/no-bare-signal-in-jsx.ts +12 -1
- package/src/rules/reactivity/no-unbatched-updates.ts +3 -0
- package/src/rules/router/no-imperative-navigate-in-render.ts +131 -35
- package/src/rules/ssr/no-window-in-ssr.ts +418 -35
- package/src/rules/store/no-duplicate-store-id.ts +11 -0
- package/src/rules/store/no-mutate-store-state.ts +11 -1
- package/src/rules/styling/no-dynamic-styled.ts +13 -24
- package/src/rules/styling/no-theme-outside-provider.ts +34 -2
- package/src/runner.ts +100 -10
- package/src/tests/runner.test.ts +1573 -21
- package/src/types.ts +74 -3
- package/src/utils/component-context.ts +106 -0
- package/src/utils/exempt-paths.ts +39 -0
- package/src/utils/file-roles.ts +32 -0
- package/src/utils/imports.ts +4 -1
- package/src/utils/validate-options.ts +68 -0
- package/src/watcher.ts +17 -0
package/lib/cli.js
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { existsSync, readFileSync, readdirSync, statSync, watch, writeFileSync } from "node:fs";
|
|
3
|
-
import { dirname, join, relative, resolve } from "node:path";
|
|
3
|
+
import path, { dirname, join, relative, resolve } from "node:path";
|
|
4
4
|
import { Visitor, parseSync } from "oxc-parser";
|
|
5
5
|
|
|
6
|
+
//#region \0rolldown/runtime.js
|
|
7
|
+
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, { get: (a, b) => (typeof require !== "undefined" ? require : a)[b] }) : x)(function(x) {
|
|
8
|
+
if (typeof require !== "undefined") return require.apply(this, arguments);
|
|
9
|
+
throw Error("Calling `require` for \"" + x + "\" in an environment that doesn't expose the `require` function. See https://rolldown.rs/in-depth/bundling-cjs#require-external-modules for more details.");
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
//#endregion
|
|
6
13
|
//#region src/cache.ts
|
|
7
14
|
/**
|
|
8
15
|
* Simple in-memory cache for parsed ASTs keyed by file content hash.
|
|
@@ -249,7 +256,6 @@ const BROWSER_GLOBALS = new Set([
|
|
|
249
256
|
"localStorage",
|
|
250
257
|
"sessionStorage",
|
|
251
258
|
"indexedDB",
|
|
252
|
-
"fetch",
|
|
253
259
|
"XMLHttpRequest",
|
|
254
260
|
"WebSocket",
|
|
255
261
|
"requestAnimationFrame",
|
|
@@ -438,6 +444,44 @@ const toastA11y = {
|
|
|
438
444
|
}
|
|
439
445
|
};
|
|
440
446
|
|
|
447
|
+
//#endregion
|
|
448
|
+
//#region src/utils/exempt-paths.ts
|
|
449
|
+
function isPathExempt(ctx) {
|
|
450
|
+
const raw = ctx.getOptions().exemptPaths;
|
|
451
|
+
if (!Array.isArray(raw) || raw.length === 0) return false;
|
|
452
|
+
const filePath = ctx.getFilePath();
|
|
453
|
+
for (const entry of raw) if (typeof entry === "string" && entry.length > 0 && filePath.includes(entry)) return true;
|
|
454
|
+
return false;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
//#endregion
|
|
458
|
+
//#region src/utils/file-roles.ts
|
|
459
|
+
/**
|
|
460
|
+
* Universal file-path classifiers for lint rules.
|
|
461
|
+
*
|
|
462
|
+
* What belongs here:
|
|
463
|
+
* - Conventions that exist in every project the linter runs on
|
|
464
|
+
* (test files, example directories — the `*.test.*` convention
|
|
465
|
+
* is not Pyreon-specific).
|
|
466
|
+
*
|
|
467
|
+
* What does NOT belong here:
|
|
468
|
+
* - Monorepo-specific paths like `packages/core/runtime-dom/` —
|
|
469
|
+
* those are implementation knowledge of one particular codebase
|
|
470
|
+
* and have no meaning in a user's app. Exemptions for such paths
|
|
471
|
+
* belong in the consuming project's lint config via the
|
|
472
|
+
* `exemptPaths: string[]` rule option — see `utils/exempt-paths.ts`
|
|
473
|
+
* and the Pyreon monorepo's `.pyreonlintrc.json` at repo root for
|
|
474
|
+
* reference.
|
|
475
|
+
*/
|
|
476
|
+
/**
|
|
477
|
+
* Matches files that are tests by convention. Universal — the
|
|
478
|
+
* `*.test.*` / `*.spec.*` / `/tests/` / `/__tests__/` conventions
|
|
479
|
+
* exist in every codebase this linter runs on, not just Pyreon.
|
|
480
|
+
*/
|
|
481
|
+
function isTestFile(filePath) {
|
|
482
|
+
return filePath.includes("/tests/") || filePath.includes("/test/") || filePath.includes("/__tests__/") || filePath.includes(".test.") || filePath.includes(".spec.");
|
|
483
|
+
}
|
|
484
|
+
|
|
441
485
|
//#endregion
|
|
442
486
|
//#region src/rules/architecture/dev-guard-warnings.ts
|
|
443
487
|
const devGuardWarnings = {
|
|
@@ -446,26 +490,122 @@ const devGuardWarnings = {
|
|
|
446
490
|
category: "architecture",
|
|
447
491
|
description: "Require console.warn/error calls to be wrapped in `if (__DEV__)` guards.",
|
|
448
492
|
severity: "error",
|
|
449
|
-
fixable: false
|
|
493
|
+
fixable: false,
|
|
494
|
+
schema: {
|
|
495
|
+
exemptPaths: "string[]",
|
|
496
|
+
devFlagNames: "string[]"
|
|
497
|
+
}
|
|
450
498
|
},
|
|
451
499
|
create(context) {
|
|
452
|
-
|
|
453
|
-
if (
|
|
500
|
+
if (isTestFile(context.getFilePath())) return {};
|
|
501
|
+
if (isPathExempt(context)) return {};
|
|
502
|
+
const userFlagNames = context.getOptions().devFlagNames;
|
|
503
|
+
const extraFlagNames = Array.isArray(userFlagNames) ? userFlagNames.filter((n) => typeof n === "string") : [];
|
|
504
|
+
const devFlagBoundConsts = /* @__PURE__ */ new Set();
|
|
505
|
+
function exprResolvesToDevFlag(expr) {
|
|
506
|
+
if (!expr) return false;
|
|
507
|
+
if (expr.type === "ChainExpression") return exprResolvesToDevFlag(expr.expression);
|
|
508
|
+
if (isDevFlag(expr)) return true;
|
|
509
|
+
if (expr.type === "BinaryExpression" && (expr.operator === "===" || expr.operator === "==")) return exprResolvesToDevFlag(expr.left) || exprResolvesToDevFlag(expr.right);
|
|
510
|
+
return false;
|
|
511
|
+
}
|
|
512
|
+
const DEV_FLAG_NAMES = new Set([
|
|
513
|
+
"__DEV__",
|
|
514
|
+
"IS_DEV",
|
|
515
|
+
"IS_DEVELOPMENT",
|
|
516
|
+
"isDev",
|
|
517
|
+
...extraFlagNames
|
|
518
|
+
]);
|
|
519
|
+
function isDevFlag(node) {
|
|
520
|
+
if (!node) return false;
|
|
521
|
+
if (node.type === "ChainExpression") return isDevFlag(node.expression);
|
|
522
|
+
if (node.type === "Identifier" && DEV_FLAG_NAMES.has(node.name)) return true;
|
|
523
|
+
if (node.type === "Identifier" && devFlagBoundConsts.has(node.name)) return true;
|
|
524
|
+
if (node.type === "MemberExpression" && node.property?.type === "Identifier" && node.property.name === "DEV") {
|
|
525
|
+
const obj = node.object;
|
|
526
|
+
if (obj?.type === "MemberExpression" && obj.property?.type === "Identifier" && obj.property.name === "env" && obj.object?.type === "MetaProperty") return true;
|
|
527
|
+
}
|
|
528
|
+
return false;
|
|
529
|
+
}
|
|
530
|
+
function containsDevGuard(test) {
|
|
531
|
+
if (!test) return false;
|
|
532
|
+
if (isDevFlag(test)) return true;
|
|
533
|
+
if (test.type === "LogicalExpression" && test.operator === "&&") return containsDevGuard(test.left) || containsDevGuard(test.right);
|
|
534
|
+
if (test.type === "BinaryExpression" && (test.operator === "===" || test.operator === "==")) return isDevFlag(test.left) || isDevFlag(test.right);
|
|
535
|
+
return false;
|
|
536
|
+
}
|
|
537
|
+
function isEarlyReturnDevGuard(node) {
|
|
538
|
+
if (!node || node.type !== "IfStatement") return false;
|
|
539
|
+
const t = node.test;
|
|
540
|
+
const arg = t?.type === "UnaryExpression" && t.operator === "!" ? t.argument : null;
|
|
541
|
+
if (!arg) return false;
|
|
542
|
+
if (!isDevFlag(arg)) return false;
|
|
543
|
+
const c = node.consequent;
|
|
544
|
+
if (c?.type === "ReturnStatement") return true;
|
|
545
|
+
if (c?.type === "BlockStatement" && c.body.length === 1 && c.body[0]?.type === "ReturnStatement") return true;
|
|
546
|
+
return false;
|
|
547
|
+
}
|
|
454
548
|
let devGuardDepth = 0;
|
|
549
|
+
let catchDepth = 0;
|
|
550
|
+
const funcGuardStack = [];
|
|
551
|
+
function enterFunction(node) {
|
|
552
|
+
const body = node?.body;
|
|
553
|
+
const stmts = body?.type === "BlockStatement" ? body.body : null;
|
|
554
|
+
let guarded = 0;
|
|
555
|
+
if (stmts && stmts.length > 0 && isEarlyReturnDevGuard(stmts[0])) {
|
|
556
|
+
guarded = 1;
|
|
557
|
+
devGuardDepth++;
|
|
558
|
+
}
|
|
559
|
+
funcGuardStack.push(guarded);
|
|
560
|
+
}
|
|
561
|
+
function exitFunction() {
|
|
562
|
+
const g = funcGuardStack.pop() ?? 0;
|
|
563
|
+
if (g > 0) devGuardDepth -= g;
|
|
564
|
+
}
|
|
455
565
|
return {
|
|
566
|
+
VariableDeclaration(node) {
|
|
567
|
+
for (const decl of node.declarations ?? []) if (decl.id?.type === "Identifier" && exprResolvesToDevFlag(decl.init)) devFlagBoundConsts.add(decl.id.name);
|
|
568
|
+
},
|
|
569
|
+
FunctionDeclaration: enterFunction,
|
|
570
|
+
"FunctionDeclaration:exit": exitFunction,
|
|
571
|
+
FunctionExpression: enterFunction,
|
|
572
|
+
"FunctionExpression:exit": exitFunction,
|
|
573
|
+
ArrowFunctionExpression: enterFunction,
|
|
574
|
+
"ArrowFunctionExpression:exit": exitFunction,
|
|
456
575
|
IfStatement(node) {
|
|
457
|
-
if (node.test
|
|
576
|
+
if (containsDevGuard(node.test)) devGuardDepth++;
|
|
458
577
|
},
|
|
459
578
|
"IfStatement:exit"(node) {
|
|
460
|
-
if (node.test
|
|
579
|
+
if (containsDevGuard(node.test)) devGuardDepth--;
|
|
580
|
+
},
|
|
581
|
+
LogicalExpression(node) {
|
|
582
|
+
if (node.operator === "&&" && containsDevGuard(node.left)) devGuardDepth++;
|
|
583
|
+
},
|
|
584
|
+
"LogicalExpression:exit"(node) {
|
|
585
|
+
if (node.operator === "&&" && containsDevGuard(node.left)) devGuardDepth--;
|
|
586
|
+
},
|
|
587
|
+
ConditionalExpression(node) {
|
|
588
|
+
if (containsDevGuard(node.test)) devGuardDepth++;
|
|
589
|
+
},
|
|
590
|
+
"ConditionalExpression:exit"(node) {
|
|
591
|
+
if (containsDevGuard(node.test)) devGuardDepth--;
|
|
592
|
+
},
|
|
593
|
+
CatchClause() {
|
|
594
|
+
catchDepth++;
|
|
595
|
+
},
|
|
596
|
+
"CatchClause:exit"() {
|
|
597
|
+
catchDepth--;
|
|
461
598
|
},
|
|
462
599
|
CallExpression(node) {
|
|
463
600
|
if (devGuardDepth > 0) return;
|
|
464
601
|
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"))
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
602
|
+
if (callee?.type === "MemberExpression" && callee.object?.type === "Identifier" && callee.object.name === "console" && callee.property?.type === "Identifier" && (callee.property.name === "warn" || callee.property.name === "error")) {
|
|
603
|
+
if (callee.property.name === "error" && catchDepth > 0) return;
|
|
604
|
+
context.report({
|
|
605
|
+
message: `\`console.${callee.property.name}()\` without \`__DEV__\` guard — dev warnings must be tree-shakeable in production. Wrap in \`if (__DEV__) { ... }\` (or \`__DEV__ && ...\`). Production error logging in \`catch\` blocks is exempt for \`console.error\`.`,
|
|
606
|
+
span: getSpan(node)
|
|
607
|
+
});
|
|
608
|
+
}
|
|
469
609
|
}
|
|
470
610
|
};
|
|
471
611
|
}
|
|
@@ -502,7 +642,9 @@ const noCircularImport = {
|
|
|
502
642
|
fixable: false
|
|
503
643
|
},
|
|
504
644
|
create(context) {
|
|
505
|
-
const
|
|
645
|
+
const filePath = context.getFilePath();
|
|
646
|
+
if (isTestFile(filePath)) return {};
|
|
647
|
+
const fileLayer = getFileLayer(filePath);
|
|
506
648
|
if (fileLayer === null) return {};
|
|
507
649
|
return { ImportDeclaration(node) {
|
|
508
650
|
const source = node.source?.value;
|
|
@@ -692,34 +834,19 @@ const noErrorWithoutPrefix = {
|
|
|
692
834
|
* Does NOT delete the const declaration — that has to happen by hand because
|
|
693
835
|
* the variable name and downstream usages may need updating in callers.
|
|
694
836
|
*
|
|
695
|
-
* **Server-
|
|
696
|
-
*
|
|
697
|
-
*
|
|
698
|
-
*
|
|
699
|
-
*
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
*
|
|
703
|
-
*
|
|
704
|
-
*
|
|
705
|
-
*
|
|
706
|
-
*
|
|
837
|
+
* **Server-only exemption**: projects configure `exemptPaths` per-file for
|
|
838
|
+
* server-only code (Node environments where `process` is always defined and
|
|
839
|
+
* the pattern is correct). Configure in `.pyreonlintrc.json`:
|
|
840
|
+
*
|
|
841
|
+
* {
|
|
842
|
+
* "rules": {
|
|
843
|
+
* "pyreon/no-process-dev-gate": [
|
|
844
|
+
* "error",
|
|
845
|
+
* { "exemptPaths": ["packages/zero/", "packages/core/server/"] }
|
|
846
|
+
* ]
|
|
847
|
+
* }
|
|
848
|
+
* }
|
|
707
849
|
*/
|
|
708
|
-
const SERVER_PACKAGE_PATTERNS = [
|
|
709
|
-
"packages/zero/",
|
|
710
|
-
"packages/core/server/",
|
|
711
|
-
"packages/core/runtime-server/",
|
|
712
|
-
"packages/tools/vite-plugin/",
|
|
713
|
-
"packages/tools/cli/",
|
|
714
|
-
"packages/tools/lint/",
|
|
715
|
-
"packages/tools/mcp/",
|
|
716
|
-
"packages/tools/storybook/",
|
|
717
|
-
"packages/tools/typescript/",
|
|
718
|
-
"scripts/"
|
|
719
|
-
];
|
|
720
|
-
function isServerOnlyFile(filePath) {
|
|
721
|
-
return SERVER_PACKAGE_PATTERNS.some((pat) => filePath.includes(pat));
|
|
722
|
-
}
|
|
723
850
|
const noProcessDevGate = {
|
|
724
851
|
meta: {
|
|
725
852
|
id: "pyreon/no-process-dev-gate",
|
|
@@ -729,9 +856,8 @@ const noProcessDevGate = {
|
|
|
729
856
|
fixable: true
|
|
730
857
|
},
|
|
731
858
|
create(context) {
|
|
732
|
-
|
|
733
|
-
if (
|
|
734
|
-
if (isServerOnlyFile(filePath)) return {};
|
|
859
|
+
if (isTestFile(context.getFilePath())) return {};
|
|
860
|
+
if (isPathExempt(context)) return {};
|
|
735
861
|
/**
|
|
736
862
|
* Match the broken pattern at the AST level. We're looking for any
|
|
737
863
|
* `LogicalExpression` whose two sides are:
|
|
@@ -788,6 +914,185 @@ const noProcessDevGate = {
|
|
|
788
914
|
}
|
|
789
915
|
};
|
|
790
916
|
|
|
917
|
+
//#endregion
|
|
918
|
+
//#region src/rules/architecture/require-browser-smoke-test.ts
|
|
919
|
+
/**
|
|
920
|
+
* `pyreon/require-browser-smoke-test` — every browser-categorized package
|
|
921
|
+
* must ship at least one `*.browser.test.{ts,tsx}` file under `src/`.
|
|
922
|
+
*
|
|
923
|
+
* Locks in the durability of the T1.1 browser smoke harness (PRs #224,
|
|
924
|
+
* #227, #229, #231). Without this rule, any new browser-running package
|
|
925
|
+
* can quietly ship without a real-browser smoke test and we drift back
|
|
926
|
+
* to the world before T1.1 — where happy-dom silently masks
|
|
927
|
+
* environment-divergence bugs (PR #197 mock-vnode metadata drop, PR
|
|
928
|
+
* #200 `typeof process` dead code, the multi-word event delegation bug
|
|
929
|
+
* fixed alongside PR #231).
|
|
930
|
+
*
|
|
931
|
+
* **What it checks**: when linting a package's `src/index.ts`, the rule
|
|
932
|
+
* looks at the package directory for any file matching
|
|
933
|
+
* `**\/*.browser.test.{ts,tsx}`. If none are found AND the package's
|
|
934
|
+
* name appears in the browser-categorized list, the rule reports an
|
|
935
|
+
* error on `src/index.ts`.
|
|
936
|
+
*
|
|
937
|
+
* **Why src/index.ts only**: the rule needs to fire exactly once per
|
|
938
|
+
* package, not per file. `src/index.ts` is a stable per-package entry
|
|
939
|
+
* point. Files inside the package are not browser-test files
|
|
940
|
+
* themselves, so they get skipped via the path check.
|
|
941
|
+
*
|
|
942
|
+
* **Default browser packages list**: matches the categorization in
|
|
943
|
+
* `.claude/rules/test-environment-parity.md`. Override via the
|
|
944
|
+
* `additionalPackages` option to opt in new packages, or via
|
|
945
|
+
* `exemptPaths` to opt out (e.g. for a brand-new package still under
|
|
946
|
+
* construction).
|
|
947
|
+
*
|
|
948
|
+
* @example Configuration in `.pyreonlintrc.json`
|
|
949
|
+
* ```json
|
|
950
|
+
* {
|
|
951
|
+
* "rules": {
|
|
952
|
+
* "pyreon/require-browser-smoke-test": [
|
|
953
|
+
* "error",
|
|
954
|
+
* {
|
|
955
|
+
* "additionalPackages": ["@my-org/my-browser-pkg"],
|
|
956
|
+
* "exemptPaths": ["packages/experimental/"]
|
|
957
|
+
* }
|
|
958
|
+
* ]
|
|
959
|
+
* }
|
|
960
|
+
* }
|
|
961
|
+
* ```
|
|
962
|
+
*
|
|
963
|
+
* **Known limitation — file existence, not test quality.** The rule only
|
|
964
|
+
* checks that at least one `*.browser.test.*` file exists under `src/`;
|
|
965
|
+
* it cannot assess whether the test is meaningful. A package could ship
|
|
966
|
+
* `sanity.browser.test.ts` with `expect(1).toBe(1)` and satisfy the
|
|
967
|
+
* rule. That's accepted by design — the rule is a *gate* against
|
|
968
|
+
* packages shipping with zero smoke coverage, not a quality check.
|
|
969
|
+
* Review the actual test contents on PR. If drive-by one-liner tests
|
|
970
|
+
* become a pattern, add a per-package coverage threshold or a
|
|
971
|
+
* complementary rule that inspects test file contents.
|
|
972
|
+
*/
|
|
973
|
+
/**
|
|
974
|
+
* Single source of truth for browser-categorized packages lives at
|
|
975
|
+
* `.claude/rules/browser-packages.json`. Loading it lazily here means:
|
|
976
|
+
*
|
|
977
|
+
* 1. Updating the list never requires re-publishing `@pyreon/lint`.
|
|
978
|
+
* 2. The script `scripts/check-browser-smoke.ts` + the human-readable
|
|
979
|
+
* `.claude/rules/test-environment-parity.md` share the same source,
|
|
980
|
+
* so they can't drift out of sync silently.
|
|
981
|
+
*
|
|
982
|
+
* The JSON is searched for by walking up from the linted file's directory
|
|
983
|
+
* to the first ancestor containing `.claude/rules/browser-packages.json`.
|
|
984
|
+
* If not found (rule running in a consumer repo that doesn't ship the
|
|
985
|
+
* JSON), the rule falls back to an empty list — `additionalPackages`
|
|
986
|
+
* becomes the only signal and the rule stays opt-in, not a footgun.
|
|
987
|
+
*
|
|
988
|
+
* Cached globally because the list is tiny and lint runs lint thousands
|
|
989
|
+
* of files per invocation.
|
|
990
|
+
*/
|
|
991
|
+
let _cachedBrowserPackages = null;
|
|
992
|
+
function loadBrowserPackages(fromFile) {
|
|
993
|
+
if (_cachedBrowserPackages) return _cachedBrowserPackages;
|
|
994
|
+
let dir = path.dirname(fromFile);
|
|
995
|
+
for (let i = 0; i < 30; i++) {
|
|
996
|
+
const candidate = path.join(dir, ".claude", "rules", "browser-packages.json");
|
|
997
|
+
if (existsSync(candidate)) {
|
|
998
|
+
try {
|
|
999
|
+
const fs = __require("node:fs");
|
|
1000
|
+
const parsed = JSON.parse(fs.readFileSync(candidate, "utf8"));
|
|
1001
|
+
if (Array.isArray(parsed.packages)) {
|
|
1002
|
+
_cachedBrowserPackages = new Set(parsed.packages.filter((p) => typeof p === "string"));
|
|
1003
|
+
return _cachedBrowserPackages;
|
|
1004
|
+
}
|
|
1005
|
+
} catch {}
|
|
1006
|
+
break;
|
|
1007
|
+
}
|
|
1008
|
+
const parent = path.dirname(dir);
|
|
1009
|
+
if (parent === dir) break;
|
|
1010
|
+
dir = parent;
|
|
1011
|
+
}
|
|
1012
|
+
_cachedBrowserPackages = /* @__PURE__ */ new Set();
|
|
1013
|
+
return _cachedBrowserPackages;
|
|
1014
|
+
}
|
|
1015
|
+
/**
|
|
1016
|
+
* Walk a directory looking for `*.browser.test.{ts,tsx}` files. Bails
|
|
1017
|
+
* on the first match — we only need to know `at least one exists`,
|
|
1018
|
+
* not enumerate them. Skips `node_modules`, `lib`, `dist`, and dot
|
|
1019
|
+
* directories so a package's own dependencies don't pollute the check.
|
|
1020
|
+
*/
|
|
1021
|
+
function hasBrowserTest(dir) {
|
|
1022
|
+
let entries;
|
|
1023
|
+
try {
|
|
1024
|
+
entries = readdirSync(dir);
|
|
1025
|
+
} catch {
|
|
1026
|
+
return false;
|
|
1027
|
+
}
|
|
1028
|
+
for (const name of entries) {
|
|
1029
|
+
if (name.startsWith(".") || name === "node_modules" || name === "lib" || name === "dist") continue;
|
|
1030
|
+
const full = path.join(dir, name);
|
|
1031
|
+
let isDir = false;
|
|
1032
|
+
try {
|
|
1033
|
+
isDir = statSync(full).isDirectory();
|
|
1034
|
+
} catch {
|
|
1035
|
+
continue;
|
|
1036
|
+
}
|
|
1037
|
+
if (isDir) {
|
|
1038
|
+
if (hasBrowserTest(full)) return true;
|
|
1039
|
+
continue;
|
|
1040
|
+
}
|
|
1041
|
+
if (/\.browser\.test\.(?:ts|tsx)$/.test(name)) return true;
|
|
1042
|
+
}
|
|
1043
|
+
return false;
|
|
1044
|
+
}
|
|
1045
|
+
/**
|
|
1046
|
+
* Read the package.json `name` field for the directory containing the
|
|
1047
|
+
* given src/index.ts file. Returns null if not found.
|
|
1048
|
+
*/
|
|
1049
|
+
function readPackageName(srcIndexPath) {
|
|
1050
|
+
const pkgPath = path.resolve(path.dirname(srcIndexPath), "..", "package.json");
|
|
1051
|
+
if (!existsSync(pkgPath)) return null;
|
|
1052
|
+
try {
|
|
1053
|
+
const text = __require("node:fs").readFileSync(pkgPath, "utf8");
|
|
1054
|
+
const parsed = JSON.parse(text);
|
|
1055
|
+
return typeof parsed.name === "string" ? parsed.name : null;
|
|
1056
|
+
} catch {
|
|
1057
|
+
return null;
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
const requireBrowserSmokeTest = {
|
|
1061
|
+
meta: {
|
|
1062
|
+
id: "pyreon/require-browser-smoke-test",
|
|
1063
|
+
category: "architecture",
|
|
1064
|
+
description: "Every browser-categorized package must ship at least one `*.browser.test.{ts,tsx}` file under `src/`. Locks in the T1.1 browser smoke harness.",
|
|
1065
|
+
severity: "error",
|
|
1066
|
+
fixable: false,
|
|
1067
|
+
schema: {
|
|
1068
|
+
additionalPackages: "string[]",
|
|
1069
|
+
exemptPaths: "string[]"
|
|
1070
|
+
}
|
|
1071
|
+
},
|
|
1072
|
+
create(context) {
|
|
1073
|
+
const filePath = context.getFilePath();
|
|
1074
|
+
if (!filePath.endsWith("/src/index.ts") && !filePath.endsWith("/src/index.tsx")) return {};
|
|
1075
|
+
if (isPathExempt(context)) return {};
|
|
1076
|
+
const pkgName = readPackageName(filePath);
|
|
1077
|
+
if (pkgName == null) return {};
|
|
1078
|
+
const options = context.getOptions();
|
|
1079
|
+
const additional = Array.isArray(options.additionalPackages) ? options.additionalPackages.filter((s) => typeof s === "string") : [];
|
|
1080
|
+
const browserPackages = new Set(loadBrowserPackages(filePath));
|
|
1081
|
+
for (const p of additional) browserPackages.add(p);
|
|
1082
|
+
if (!browserPackages.has(pkgName)) return {};
|
|
1083
|
+
if (hasBrowserTest(path.dirname(path.dirname(filePath)))) return {};
|
|
1084
|
+
return { "Program:exit"(node) {
|
|
1085
|
+
context.report({
|
|
1086
|
+
message: `[Pyreon] Browser-categorized package "${pkgName}" has no \`*.browser.test.{ts,tsx}\` file. Add at least one real-browser smoke test under \`src/\` to catch environment-divergence bugs that happy-dom hides (typeof process dead code, real pointer events, computed styles, etc.). See .claude/rules/test-environment-parity.md for the recipe.`,
|
|
1087
|
+
span: {
|
|
1088
|
+
start: node.start ?? 0,
|
|
1089
|
+
end: node.end ?? 0
|
|
1090
|
+
}
|
|
1091
|
+
});
|
|
1092
|
+
} };
|
|
1093
|
+
}
|
|
1094
|
+
};
|
|
1095
|
+
|
|
791
1096
|
//#endregion
|
|
792
1097
|
//#region src/rules/form/no-submit-without-validation.ts
|
|
793
1098
|
const noSubmitWithoutValidation = {
|
|
@@ -799,6 +1104,7 @@ const noSubmitWithoutValidation = {
|
|
|
799
1104
|
fixable: false
|
|
800
1105
|
},
|
|
801
1106
|
create(context) {
|
|
1107
|
+
if (isTestFile(context.getFilePath())) return {};
|
|
802
1108
|
return { CallExpression(node) {
|
|
803
1109
|
if (!isCallTo(node, "useForm")) return;
|
|
804
1110
|
const args = node.arguments;
|
|
@@ -834,6 +1140,7 @@ const noUnregisteredField = {
|
|
|
834
1140
|
fixable: false
|
|
835
1141
|
},
|
|
836
1142
|
create(context) {
|
|
1143
|
+
if (isTestFile(context.getFilePath())) return {};
|
|
837
1144
|
const fieldDecls = /* @__PURE__ */ new Map();
|
|
838
1145
|
const registeredNames = /* @__PURE__ */ new Set();
|
|
839
1146
|
return {
|
|
@@ -899,9 +1206,11 @@ const noRawAddEventListener = {
|
|
|
899
1206
|
category: "hooks",
|
|
900
1207
|
description: "Suggest useEventListener() instead of raw .addEventListener() calls.",
|
|
901
1208
|
severity: "info",
|
|
902
|
-
fixable: false
|
|
1209
|
+
fixable: false,
|
|
1210
|
+
schema: { exemptPaths: "string[]" }
|
|
903
1211
|
},
|
|
904
1212
|
create(context) {
|
|
1213
|
+
if (isPathExempt(context)) return {};
|
|
905
1214
|
return { CallExpression(node) {
|
|
906
1215
|
const callee = node.callee;
|
|
907
1216
|
if (!callee || callee.type !== "MemberExpression") return;
|
|
@@ -914,6 +1223,41 @@ const noRawAddEventListener = {
|
|
|
914
1223
|
}
|
|
915
1224
|
};
|
|
916
1225
|
|
|
1226
|
+
//#endregion
|
|
1227
|
+
//#region src/utils/component-context.ts
|
|
1228
|
+
const COMPONENT_NAME = /^[A-Z]/;
|
|
1229
|
+
const HOOK_NAME$1 = /^use[A-Z]/;
|
|
1230
|
+
function isComponentOrHookName(name) {
|
|
1231
|
+
if (!name) return false;
|
|
1232
|
+
return COMPONENT_NAME.test(name) || HOOK_NAME$1.test(name);
|
|
1233
|
+
}
|
|
1234
|
+
function createComponentContextTracker() {
|
|
1235
|
+
let depth = 0;
|
|
1236
|
+
function declaratorIsComponentOrHook(node) {
|
|
1237
|
+
if (node?.id?.type !== "Identifier") return false;
|
|
1238
|
+
const init = node.init;
|
|
1239
|
+
if (init?.type !== "ArrowFunctionExpression" && init?.type !== "FunctionExpression") return false;
|
|
1240
|
+
return isComponentOrHookName(node.id.name);
|
|
1241
|
+
}
|
|
1242
|
+
return {
|
|
1243
|
+
isInComponentOrHook: () => depth > 0,
|
|
1244
|
+
callbacks: {
|
|
1245
|
+
FunctionDeclaration(node) {
|
|
1246
|
+
if (isComponentOrHookName(node.id?.name)) depth++;
|
|
1247
|
+
},
|
|
1248
|
+
"FunctionDeclaration:exit"(node) {
|
|
1249
|
+
if (isComponentOrHookName(node.id?.name)) depth--;
|
|
1250
|
+
},
|
|
1251
|
+
VariableDeclarator(node) {
|
|
1252
|
+
if (declaratorIsComponentOrHook(node)) depth++;
|
|
1253
|
+
},
|
|
1254
|
+
"VariableDeclarator:exit"(node) {
|
|
1255
|
+
if (declaratorIsComponentOrHook(node)) depth--;
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
};
|
|
1259
|
+
}
|
|
1260
|
+
|
|
917
1261
|
//#endregion
|
|
918
1262
|
//#region src/rules/hooks/no-raw-localstorage.ts
|
|
919
1263
|
const STORAGE_OBJECTS = new Set(["localStorage", "sessionStorage"]);
|
|
@@ -926,19 +1270,24 @@ const noRawLocalStorage = {
|
|
|
926
1270
|
meta: {
|
|
927
1271
|
id: "pyreon/no-raw-localstorage",
|
|
928
1272
|
category: "hooks",
|
|
929
|
-
description: "Suggest useStorage() instead of raw localStorage/sessionStorage
|
|
1273
|
+
description: "Suggest useStorage() instead of raw localStorage/sessionStorage inside a component or hook.",
|
|
930
1274
|
severity: "info",
|
|
931
1275
|
fixable: false
|
|
932
1276
|
},
|
|
933
1277
|
create(context) {
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
1278
|
+
const ctx = createComponentContextTracker();
|
|
1279
|
+
return {
|
|
1280
|
+
...ctx.callbacks,
|
|
1281
|
+
CallExpression(node) {
|
|
1282
|
+
if (!ctx.isInComponentOrHook()) return;
|
|
1283
|
+
const callee = node.callee;
|
|
1284
|
+
if (!callee || callee.type !== "MemberExpression") return;
|
|
1285
|
+
if (callee.object?.type === "Identifier" && STORAGE_OBJECTS.has(callee.object.name) && callee.property?.type === "Identifier" && STORAGE_METHODS.has(callee.property.name)) context.report({
|
|
1286
|
+
message: `Raw \`${callee.object.name}.${callee.property.name}()\` — consider using \`useStorage()\` from \`@pyreon/storage\` for reactive, cross-tab synced storage.`,
|
|
1287
|
+
span: getSpan(node)
|
|
1288
|
+
});
|
|
1289
|
+
}
|
|
1290
|
+
};
|
|
942
1291
|
}
|
|
943
1292
|
};
|
|
944
1293
|
|
|
@@ -951,14 +1300,19 @@ const noRawSetInterval = {
|
|
|
951
1300
|
category: "hooks",
|
|
952
1301
|
description: "Suggest wrapping setInterval/setTimeout in onMount for automatic cleanup.",
|
|
953
1302
|
severity: "info",
|
|
954
|
-
fixable: false
|
|
1303
|
+
fixable: false,
|
|
1304
|
+
schema: { exemptPaths: "string[]" }
|
|
955
1305
|
},
|
|
956
1306
|
create(context) {
|
|
1307
|
+
if (isPathExempt(context)) return {};
|
|
1308
|
+
const ctx = createComponentContextTracker();
|
|
957
1309
|
let mountDepth = 0;
|
|
958
1310
|
return {
|
|
1311
|
+
...ctx.callbacks,
|
|
959
1312
|
CallExpression(node) {
|
|
960
1313
|
if (isCallTo(node, "onMount")) mountDepth++;
|
|
961
1314
|
if (mountDepth > 0) return;
|
|
1315
|
+
if (!ctx.isInComponentOrHook()) return;
|
|
962
1316
|
const callee = node.callee;
|
|
963
1317
|
if (!callee || callee.type !== "Identifier") return;
|
|
964
1318
|
if (TIMER_FNS.has(callee.name)) context.report({
|
|
@@ -1268,24 +1622,28 @@ const noPropsDestructure = {
|
|
|
1268
1622
|
},
|
|
1269
1623
|
create(context) {
|
|
1270
1624
|
let functionDepth = 0;
|
|
1625
|
+
const callArgFns = /* @__PURE__ */ new WeakSet();
|
|
1271
1626
|
return {
|
|
1627
|
+
CallExpression(node) {
|
|
1628
|
+
for (const arg of node.arguments ?? []) if (arg?.type === "ArrowFunctionExpression" || arg?.type === "FunctionExpression" || arg?.type === "FunctionDeclaration") callArgFns.add(arg);
|
|
1629
|
+
},
|
|
1272
1630
|
ArrowFunctionExpression(node) {
|
|
1273
1631
|
functionDepth++;
|
|
1274
|
-
checkFunction(node, context, functionDepth);
|
|
1632
|
+
checkFunction(node, context, functionDepth, callArgFns);
|
|
1275
1633
|
},
|
|
1276
1634
|
"ArrowFunctionExpression:exit"() {
|
|
1277
1635
|
functionDepth--;
|
|
1278
1636
|
},
|
|
1279
1637
|
FunctionDeclaration(node) {
|
|
1280
1638
|
functionDepth++;
|
|
1281
|
-
checkFunction(node, context, functionDepth);
|
|
1639
|
+
checkFunction(node, context, functionDepth, callArgFns);
|
|
1282
1640
|
},
|
|
1283
1641
|
"FunctionDeclaration:exit"() {
|
|
1284
1642
|
functionDepth--;
|
|
1285
1643
|
},
|
|
1286
1644
|
FunctionExpression(node) {
|
|
1287
1645
|
functionDepth++;
|
|
1288
|
-
checkFunction(node, context, functionDepth);
|
|
1646
|
+
checkFunction(node, context, functionDepth, callArgFns);
|
|
1289
1647
|
},
|
|
1290
1648
|
"FunctionExpression:exit"() {
|
|
1291
1649
|
functionDepth--;
|
|
@@ -1293,14 +1651,13 @@ const noPropsDestructure = {
|
|
|
1293
1651
|
};
|
|
1294
1652
|
}
|
|
1295
1653
|
};
|
|
1296
|
-
function checkFunction(node, context, depth) {
|
|
1654
|
+
function checkFunction(node, context, depth, callArgFns) {
|
|
1297
1655
|
const params = node.params;
|
|
1298
1656
|
if (!params || params.length === 0) return;
|
|
1299
1657
|
const firstParam = params[0];
|
|
1300
1658
|
if (!isDestructuring(firstParam)) return;
|
|
1301
1659
|
if (depth > 1) return;
|
|
1302
|
-
|
|
1303
|
-
if (parent?.type === "CallExpression" && parent.arguments?.includes(node)) return;
|
|
1660
|
+
if (callArgFns.has(node)) return;
|
|
1304
1661
|
const body = node.body;
|
|
1305
1662
|
if (!body) return;
|
|
1306
1663
|
if (containsJSXReturn(body)) {
|
|
@@ -1397,9 +1754,47 @@ const noDomInSetup = {
|
|
|
1397
1754
|
},
|
|
1398
1755
|
create(context) {
|
|
1399
1756
|
let safeDepth = 0;
|
|
1757
|
+
function isSafeContextCall(node) {
|
|
1758
|
+
return isCallTo(node, "onMount") || isCallTo(node, "onUnmount") || isCallTo(node, "onCleanup") || isCallTo(node, "effect") || isCallTo(node, "renderEffect") || isCallTo(node, "requestAnimationFrame");
|
|
1759
|
+
}
|
|
1760
|
+
function isNegatedTypeofDocument(test) {
|
|
1761
|
+
if (!test) return false;
|
|
1762
|
+
if (test.type === "BinaryExpression" && (test.operator === "===" || test.operator === "==") && test.left?.type === "UnaryExpression" && test.left.operator === "typeof" && test.left.argument?.type === "Identifier" && (test.left.argument.name === "document" || test.left.argument.name === "window")) return true;
|
|
1763
|
+
return false;
|
|
1764
|
+
}
|
|
1765
|
+
function isEarlyReturnDocumentGuard(stmt) {
|
|
1766
|
+
if (!stmt || stmt.type !== "IfStatement") return false;
|
|
1767
|
+
if (!isNegatedTypeofDocument(stmt.test)) return false;
|
|
1768
|
+
const c = stmt.consequent;
|
|
1769
|
+
const isTerminator = (s) => s?.type === "ReturnStatement" || s?.type === "ThrowStatement";
|
|
1770
|
+
if (isTerminator(c)) return true;
|
|
1771
|
+
if (c?.type === "BlockStatement" && c.body.length === 1 && isTerminator(c.body[0])) return true;
|
|
1772
|
+
return false;
|
|
1773
|
+
}
|
|
1774
|
+
const earlyReturnStack = [];
|
|
1775
|
+
function pushFunctionScope(node) {
|
|
1776
|
+
const body = node?.body;
|
|
1777
|
+
const stmts = body?.type === "BlockStatement" ? body.body : null;
|
|
1778
|
+
let bumps = 0;
|
|
1779
|
+
if (stmts && stmts.length > 0 && isEarlyReturnDocumentGuard(stmts[0])) {
|
|
1780
|
+
bumps = 1;
|
|
1781
|
+
safeDepth++;
|
|
1782
|
+
}
|
|
1783
|
+
earlyReturnStack.push(bumps);
|
|
1784
|
+
}
|
|
1785
|
+
function popFunctionScope() {
|
|
1786
|
+
const bumps = earlyReturnStack.pop() ?? 0;
|
|
1787
|
+
if (bumps > 0) safeDepth -= bumps;
|
|
1788
|
+
}
|
|
1400
1789
|
return {
|
|
1790
|
+
FunctionDeclaration: pushFunctionScope,
|
|
1791
|
+
"FunctionDeclaration:exit": popFunctionScope,
|
|
1792
|
+
FunctionExpression: pushFunctionScope,
|
|
1793
|
+
"FunctionExpression:exit": popFunctionScope,
|
|
1794
|
+
ArrowFunctionExpression: pushFunctionScope,
|
|
1795
|
+
"ArrowFunctionExpression:exit": popFunctionScope,
|
|
1401
1796
|
CallExpression(node) {
|
|
1402
|
-
if (
|
|
1797
|
+
if (isSafeContextCall(node)) safeDepth++;
|
|
1403
1798
|
if (safeDepth > 0) return;
|
|
1404
1799
|
const callee = node.callee;
|
|
1405
1800
|
if (callee?.type === "MemberExpression" && callee.object?.type === "Identifier" && callee.object.name === "document" && callee.property?.type === "Identifier" && DOM_METHODS.has(callee.property.name)) context.report({
|
|
@@ -1408,7 +1803,7 @@ const noDomInSetup = {
|
|
|
1408
1803
|
});
|
|
1409
1804
|
},
|
|
1410
1805
|
"CallExpression:exit"(node) {
|
|
1411
|
-
if (
|
|
1806
|
+
if (isSafeContextCall(node)) safeDepth--;
|
|
1412
1807
|
}
|
|
1413
1808
|
};
|
|
1414
1809
|
}
|
|
@@ -1630,6 +2025,11 @@ const preferShowOverDisplay = {
|
|
|
1630
2025
|
|
|
1631
2026
|
//#endregion
|
|
1632
2027
|
//#region src/rules/reactivity/no-bare-signal-in-jsx.ts
|
|
2028
|
+
const SKIP_NAMES = new Set([
|
|
2029
|
+
"render",
|
|
2030
|
+
"h",
|
|
2031
|
+
"cloneVNode"
|
|
2032
|
+
]);
|
|
1633
2033
|
const SKIP_PREFIXES = /^(use|get|is|has|[A-Z])/;
|
|
1634
2034
|
const noBareSignalInJsx = {
|
|
1635
2035
|
meta: {
|
|
@@ -1661,7 +2061,7 @@ const noBareSignalInJsx = {
|
|
|
1661
2061
|
const callee = expr.callee;
|
|
1662
2062
|
if (!callee || callee.type !== "Identifier") return;
|
|
1663
2063
|
const name = callee.name;
|
|
1664
|
-
if (SKIP_PREFIXES.test(name)) return;
|
|
2064
|
+
if (SKIP_NAMES.has(name) || SKIP_PREFIXES.test(name)) return;
|
|
1665
2065
|
const span = getSpan(node);
|
|
1666
2066
|
const fixed = `{() => ${context.getSourceText().slice(span.start, span.end).slice(1, -1)}}`;
|
|
1667
2067
|
context.report({
|
|
@@ -1962,9 +2362,11 @@ const noUnbatchedUpdates = {
|
|
|
1962
2362
|
category: "reactivity",
|
|
1963
2363
|
description: "Warn when 3+ .set() calls occur in the same function without batch().",
|
|
1964
2364
|
severity: "warn",
|
|
1965
|
-
fixable: false
|
|
2365
|
+
fixable: false,
|
|
2366
|
+
schema: { exemptPaths: "string[]" }
|
|
1966
2367
|
},
|
|
1967
2368
|
create(context) {
|
|
2369
|
+
if (isPathExempt(context)) return {};
|
|
1968
2370
|
const scopeStack = [];
|
|
1969
2371
|
let batchDepth = 0;
|
|
1970
2372
|
function enterScope(node) {
|
|
@@ -2113,46 +2515,94 @@ const noImperativeNavigateInRender = {
|
|
|
2113
2515
|
},
|
|
2114
2516
|
create(context) {
|
|
2115
2517
|
let componentBodyDepth = 0;
|
|
2116
|
-
let
|
|
2117
|
-
|
|
2518
|
+
let nestedFnDepth = 0;
|
|
2519
|
+
const componentInits = /* @__PURE__ */ new WeakSet();
|
|
2520
|
+
const dangerousBindings = [];
|
|
2521
|
+
const nestedFnStack = [];
|
|
2522
|
+
function isComponentFunctionDecl(node) {
|
|
2523
|
+
return /^[A-Z]/.test(node.id?.name ?? "");
|
|
2524
|
+
}
|
|
2525
|
+
function enterNestedFn(node, bindingName) {
|
|
2526
|
+
nestedFnDepth++;
|
|
2527
|
+
nestedFnStack.push({
|
|
2528
|
+
containsNavigate: false,
|
|
2529
|
+
bindingName
|
|
2530
|
+
});
|
|
2531
|
+
}
|
|
2532
|
+
function exitNestedFn() {
|
|
2533
|
+
nestedFnDepth--;
|
|
2534
|
+
const frame = nestedFnStack.pop();
|
|
2535
|
+
if (!frame) return;
|
|
2536
|
+
if (frame.containsNavigate && frame.bindingName && dangerousBindings.length > 0) dangerousBindings[dangerousBindings.length - 1].add(frame.bindingName);
|
|
2537
|
+
}
|
|
2538
|
+
function isNavigateCall(node) {
|
|
2539
|
+
return isCallTo(node, "navigate") || isMemberCallTo(node, "router", "push");
|
|
2540
|
+
}
|
|
2541
|
+
const callbacks = {
|
|
2118
2542
|
FunctionDeclaration(node) {
|
|
2119
|
-
|
|
2120
|
-
|
|
2543
|
+
if (isComponentFunctionDecl(node)) {
|
|
2544
|
+
componentBodyDepth++;
|
|
2545
|
+
dangerousBindings.push(/* @__PURE__ */ new Set());
|
|
2546
|
+
} else if (componentBodyDepth > 0) enterNestedFn(node, node.id?.type === "Identifier" ? node.id.name : null);
|
|
2121
2547
|
},
|
|
2122
2548
|
"FunctionDeclaration:exit"(node) {
|
|
2123
|
-
|
|
2124
|
-
|
|
2549
|
+
if (isComponentFunctionDecl(node)) {
|
|
2550
|
+
componentBodyDepth--;
|
|
2551
|
+
dangerousBindings.pop();
|
|
2552
|
+
} else if (componentBodyDepth > 0) exitNestedFn();
|
|
2125
2553
|
},
|
|
2126
2554
|
VariableDeclarator(node) {
|
|
2127
|
-
|
|
2128
|
-
|
|
2555
|
+
if (/^[A-Z]/.test(node.id?.name ?? "") && (node.init?.type === "ArrowFunctionExpression" || node.init?.type === "FunctionExpression")) {
|
|
2556
|
+
componentBodyDepth++;
|
|
2557
|
+
dangerousBindings.push(/* @__PURE__ */ new Set());
|
|
2558
|
+
componentInits.add(node.init);
|
|
2559
|
+
}
|
|
2129
2560
|
},
|
|
2130
2561
|
"VariableDeclarator:exit"(node) {
|
|
2131
|
-
|
|
2132
|
-
|
|
2562
|
+
if (/^[A-Z]/.test(node.id?.name ?? "") && (node.init?.type === "ArrowFunctionExpression" || node.init?.type === "FunctionExpression")) {
|
|
2563
|
+
componentBodyDepth--;
|
|
2564
|
+
dangerousBindings.pop();
|
|
2565
|
+
}
|
|
2566
|
+
},
|
|
2567
|
+
ArrowFunctionExpression(node) {
|
|
2568
|
+
if (componentInits.has(node)) return;
|
|
2569
|
+
if (componentBodyDepth > 0) enterNestedFn(node, bindingAssignmentNames.get(node) ?? null);
|
|
2570
|
+
},
|
|
2571
|
+
"ArrowFunctionExpression:exit"(node) {
|
|
2572
|
+
if (componentInits.has(node)) return;
|
|
2573
|
+
if (componentBodyDepth > 0) exitNestedFn();
|
|
2574
|
+
},
|
|
2575
|
+
FunctionExpression(node) {
|
|
2576
|
+
if (componentInits.has(node)) return;
|
|
2577
|
+
if (componentBodyDepth > 0) enterNestedFn(node, bindingAssignmentNames.get(node) ?? null);
|
|
2578
|
+
},
|
|
2579
|
+
"FunctionExpression:exit"(node) {
|
|
2580
|
+
if (componentInits.has(node)) return;
|
|
2581
|
+
if (componentBodyDepth > 0) exitNestedFn();
|
|
2133
2582
|
},
|
|
2134
2583
|
CallExpression(node) {
|
|
2135
2584
|
if (componentBodyDepth <= 0) return;
|
|
2136
|
-
if (
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2585
|
+
if (isNavigateCall(node)) {
|
|
2586
|
+
if (nestedFnDepth === 0) context.report({
|
|
2587
|
+
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.",
|
|
2588
|
+
span: getSpan(node)
|
|
2589
|
+
});
|
|
2590
|
+
else if (nestedFnStack.length > 0) nestedFnStack[nestedFnStack.length - 1].containsNavigate = true;
|
|
2591
|
+
return;
|
|
2592
|
+
}
|
|
2593
|
+
if (nestedFnDepth === 0 && node.callee?.type === "Identifier" && dangerousBindings.length > 0 && dangerousBindings[dangerousBindings.length - 1].has(node.callee.name)) context.report({
|
|
2594
|
+
message: "Synchronous call of a nested function that performs imperative navigation — this runs during render and causes infinite loops. Move the call inside `onMount`, `effect`, or an event handler.",
|
|
2140
2595
|
span: getSpan(node)
|
|
2141
2596
|
});
|
|
2142
|
-
},
|
|
2143
|
-
"CallExpression:exit"(node) {
|
|
2144
|
-
if (componentBodyDepth <= 0) return;
|
|
2145
|
-
if (isSafeWrapperCall(node)) safeDepth--;
|
|
2146
2597
|
}
|
|
2147
2598
|
};
|
|
2599
|
+
const bindingAssignmentNames = /* @__PURE__ */ new WeakMap();
|
|
2600
|
+
callbacks.VariableDeclaration = (node) => {
|
|
2601
|
+
for (const decl of node.declarations ?? []) if (decl.id?.type === "Identifier" && (decl.init?.type === "ArrowFunctionExpression" || decl.init?.type === "FunctionExpression")) bindingAssignmentNames.set(decl.init, decl.id.name);
|
|
2602
|
+
};
|
|
2603
|
+
return callbacks;
|
|
2148
2604
|
}
|
|
2149
2605
|
};
|
|
2150
|
-
function isSafeWrapperCall(node) {
|
|
2151
|
-
const callee = node.callee;
|
|
2152
|
-
if (!callee || callee.type !== "Identifier") return false;
|
|
2153
|
-
const name = callee.name;
|
|
2154
|
-
return name === "onMount" || name === "effect" || name === "onUnmount";
|
|
2155
|
-
}
|
|
2156
2606
|
|
|
2157
2607
|
//#endregion
|
|
2158
2608
|
//#region src/rules/router/no-missing-fallback.ts
|
|
@@ -2299,32 +2749,236 @@ const noWindowInSsr = {
|
|
|
2299
2749
|
category: "ssr",
|
|
2300
2750
|
description: "Disallow browser globals outside onMount/effect/typeof guards — they break SSR.",
|
|
2301
2751
|
severity: "error",
|
|
2302
|
-
fixable: false
|
|
2752
|
+
fixable: false,
|
|
2753
|
+
schema: { exemptPaths: "string[]" }
|
|
2303
2754
|
},
|
|
2304
2755
|
create(context) {
|
|
2756
|
+
if (isPathExempt(context)) return {};
|
|
2305
2757
|
let safeDepth = 0;
|
|
2306
2758
|
let typeofGuardDepth = 0;
|
|
2759
|
+
let inTypeofExpr = 0;
|
|
2760
|
+
const skipPropertyNodes = /* @__PURE__ */ new WeakSet();
|
|
2761
|
+
let inTsTypePos = 0;
|
|
2762
|
+
const typeofBoundConsts = /* @__PURE__ */ new Set();
|
|
2763
|
+
function isPositiveTypeofCheck(expr) {
|
|
2764
|
+
if (!expr) return false;
|
|
2765
|
+
if (expr.type === "BinaryExpression" && (expr.operator === "!==" || expr.operator === "!=") && expr.left?.type === "UnaryExpression" && expr.left.operator === "typeof") return true;
|
|
2766
|
+
if (expr.type === "UnaryExpression" && expr.operator === "typeof") return true;
|
|
2767
|
+
return false;
|
|
2768
|
+
}
|
|
2769
|
+
/** Used by VariableDeclaration to decide whether to bind a const. */
|
|
2770
|
+
function isTypeofCheckForBinding(expr) {
|
|
2771
|
+
if (!expr) return false;
|
|
2772
|
+
if (expr.type === "BinaryExpression" && expr.left?.type === "UnaryExpression" && expr.left.operator === "typeof") return true;
|
|
2773
|
+
if (expr.type === "UnaryExpression" && expr.operator === "typeof") return true;
|
|
2774
|
+
if (expr.type === "LogicalExpression" && expr.operator === "&&") return isTypeofCheckForBinding(expr.left) || isTypeofCheckForBinding(expr.right);
|
|
2775
|
+
if (expr.type === "ConditionalExpression") return isTypeofCheckForBinding(expr.test);
|
|
2776
|
+
if (expr.type === "Identifier" && typeofBoundConsts.has(expr.name)) return true;
|
|
2777
|
+
return false;
|
|
2778
|
+
}
|
|
2779
|
+
/**
|
|
2780
|
+
* `if (test) { … }` — does the test indicate the body is the
|
|
2781
|
+
* BROWSER-SAFE branch? Only positive forms qualify here. Negated
|
|
2782
|
+
* forms (`typeof X === 'undefined'`, `!isBrowser`) are early-return
|
|
2783
|
+
* guards handled separately.
|
|
2784
|
+
*/
|
|
2785
|
+
function testIsTypeofGuard(test) {
|
|
2786
|
+
if (!test) return false;
|
|
2787
|
+
if (isPositiveTypeofCheck(test)) return true;
|
|
2788
|
+
if (test.type === "Identifier" && typeofBoundConsts.has(test.name)) return true;
|
|
2789
|
+
if (test.type === "CallExpression" && test.callee?.type === "Identifier" && typeofGuardFunctions.has(test.callee.name)) return true;
|
|
2790
|
+
if (test.type === "LogicalExpression" && test.operator === "&&") return testIsTypeofGuard(test.left) || testIsTypeofGuard(test.right);
|
|
2791
|
+
return false;
|
|
2792
|
+
}
|
|
2793
|
+
const typeofGuardFunctions = new Set([
|
|
2794
|
+
"isBrowser",
|
|
2795
|
+
"isClient",
|
|
2796
|
+
"isServer",
|
|
2797
|
+
"isSSR"
|
|
2798
|
+
]);
|
|
2799
|
+
function bodyIsTypeofGuard(body) {
|
|
2800
|
+
if (!body) return false;
|
|
2801
|
+
if (body.type !== "BlockStatement") return isReturnedTypeofExpr(body);
|
|
2802
|
+
const stmts = body.body ?? [];
|
|
2803
|
+
if (stmts.length !== 1) return false;
|
|
2804
|
+
const stmt = stmts[0];
|
|
2805
|
+
if (stmt?.type !== "ReturnStatement") return false;
|
|
2806
|
+
return isReturnedTypeofExpr(stmt.argument);
|
|
2807
|
+
}
|
|
2808
|
+
function isReturnedTypeofExpr(expr) {
|
|
2809
|
+
if (!expr) return false;
|
|
2810
|
+
if (isPositiveTypeofCheck(expr)) return true;
|
|
2811
|
+
if (expr.type === "LogicalExpression" && expr.operator === "&&") return isReturnedTypeofExpr(expr.left) && isReturnedTypeofExpr(expr.right);
|
|
2812
|
+
if (expr.type === "Identifier" && typeofBoundConsts.has(expr.name)) return true;
|
|
2813
|
+
return false;
|
|
2814
|
+
}
|
|
2815
|
+
function isNegatedTypeofExpr(test) {
|
|
2816
|
+
if (!test) return false;
|
|
2817
|
+
if (test.type === "BinaryExpression" && (test.operator === "===" || test.operator === "==") && test.left?.type === "UnaryExpression" && test.left.operator === "typeof") return true;
|
|
2818
|
+
if (test.type === "UnaryExpression" && test.operator === "!" && test.argument?.type === "Identifier" && typeofBoundConsts.has(test.argument.name)) return true;
|
|
2819
|
+
if (test.type === "UnaryExpression" && test.operator === "!" && test.argument?.type === "CallExpression" && test.argument.callee?.type === "Identifier" && typeofGuardFunctions.has(test.argument.callee.name)) return true;
|
|
2820
|
+
if (test.type === "LogicalExpression" && test.operator === "||") return isNegatedTypeofExpr(test.left) && isNegatedTypeofExpr(test.right);
|
|
2821
|
+
return false;
|
|
2822
|
+
}
|
|
2823
|
+
function isEarlyReturnTypeofGuard(stmt) {
|
|
2824
|
+
if (!stmt || stmt.type !== "IfStatement") return false;
|
|
2825
|
+
if (!isNegatedTypeofExpr(stmt.test)) return false;
|
|
2826
|
+
const c = stmt.consequent;
|
|
2827
|
+
const isTerminator = (s) => s?.type === "ReturnStatement" || s?.type === "ThrowStatement";
|
|
2828
|
+
if (isTerminator(c)) return true;
|
|
2829
|
+
if (c?.type === "BlockStatement" && c.body.length === 1 && isTerminator(c.body[0])) return true;
|
|
2830
|
+
return false;
|
|
2831
|
+
}
|
|
2832
|
+
const earlyReturnStack = [];
|
|
2833
|
+
const watchCallbackNodes = /* @__PURE__ */ new WeakSet();
|
|
2834
|
+
const watchCallbackSafeDepthStack = [];
|
|
2835
|
+
const shadowedNamesStack = [];
|
|
2836
|
+
const importShadowedNames = /* @__PURE__ */ new Set();
|
|
2837
|
+
function collectParamNames(params) {
|
|
2838
|
+
const names = /* @__PURE__ */ new Set();
|
|
2839
|
+
const walk = (p) => {
|
|
2840
|
+
if (!p) return;
|
|
2841
|
+
if (p.type === "Identifier" && BROWSER_GLOBALS.has(p.name)) names.add(p.name);
|
|
2842
|
+
else if (p.type === "AssignmentPattern") walk(p.left);
|
|
2843
|
+
else if (p.type === "RestElement") walk(p.argument);
|
|
2844
|
+
else if (p.type === "ArrayPattern") for (const el of p.elements ?? []) walk(el);
|
|
2845
|
+
else if (p.type === "ObjectPattern") for (const prop of p.properties ?? []) if (prop.type === "RestElement") walk(prop.argument);
|
|
2846
|
+
else walk(prop.value);
|
|
2847
|
+
};
|
|
2848
|
+
for (const p of params ?? []) walk(p);
|
|
2849
|
+
return names;
|
|
2850
|
+
}
|
|
2851
|
+
function isNameShadowed(name) {
|
|
2852
|
+
for (let i = shadowedNamesStack.length - 1; i >= 0; i--) if (shadowedNamesStack[i].has(name)) return true;
|
|
2853
|
+
return false;
|
|
2854
|
+
}
|
|
2855
|
+
function pushFunctionScope(node) {
|
|
2856
|
+
earlyReturnStack.push(0);
|
|
2857
|
+
shadowedNamesStack.push(node ? collectParamNames(node.params ?? []) : /* @__PURE__ */ new Set());
|
|
2858
|
+
if (node && watchCallbackNodes.has(node)) {
|
|
2859
|
+
safeDepth++;
|
|
2860
|
+
watchCallbackSafeDepthStack.push(1);
|
|
2861
|
+
} else watchCallbackSafeDepthStack.push(0);
|
|
2862
|
+
}
|
|
2863
|
+
function popFunctionScope() {
|
|
2864
|
+
const bumps = earlyReturnStack.pop() ?? 0;
|
|
2865
|
+
typeofGuardDepth -= bumps;
|
|
2866
|
+
shadowedNamesStack.pop();
|
|
2867
|
+
const watchBump = watchCallbackSafeDepthStack.pop() ?? 0;
|
|
2868
|
+
if (watchBump > 0) safeDepth -= watchBump;
|
|
2869
|
+
}
|
|
2870
|
+
function noteEarlyReturnGuardVisit() {
|
|
2871
|
+
typeofGuardDepth++;
|
|
2872
|
+
if (earlyReturnStack.length > 0) earlyReturnStack[earlyReturnStack.length - 1]++;
|
|
2873
|
+
}
|
|
2307
2874
|
return {
|
|
2875
|
+
VariableDeclaration(node) {
|
|
2876
|
+
for (const decl of node.declarations ?? []) {
|
|
2877
|
+
if (decl.id?.type !== "Identifier") continue;
|
|
2878
|
+
if (isTypeofCheckForBinding(decl.init)) typeofBoundConsts.add(decl.id.name);
|
|
2879
|
+
if ((decl.init?.type === "ArrowFunctionExpression" || decl.init?.type === "FunctionExpression") && bodyIsTypeofGuard(decl.init.body)) typeofGuardFunctions.add(decl.id.name);
|
|
2880
|
+
}
|
|
2881
|
+
},
|
|
2882
|
+
FunctionDeclaration(node) {
|
|
2883
|
+
if (node.id?.type === "Identifier" && bodyIsTypeofGuard(node.body)) typeofGuardFunctions.add(node.id.name);
|
|
2884
|
+
pushFunctionScope(node);
|
|
2885
|
+
},
|
|
2886
|
+
"FunctionDeclaration:exit": popFunctionScope,
|
|
2887
|
+
FunctionExpression: pushFunctionScope,
|
|
2888
|
+
"FunctionExpression:exit": popFunctionScope,
|
|
2889
|
+
ArrowFunctionExpression: pushFunctionScope,
|
|
2890
|
+
"ArrowFunctionExpression:exit": popFunctionScope,
|
|
2308
2891
|
CallExpression(node) {
|
|
2309
|
-
if (isCallTo(node, "onMount") || isCallTo(node, "effect"))
|
|
2892
|
+
if (isCallTo(node, "onMount") || isCallTo(node, "onUnmount") || isCallTo(node, "onCleanup") || isCallTo(node, "effect") || isCallTo(node, "renderEffect") || isCallTo(node, "requestAnimationFrame")) {
|
|
2893
|
+
safeDepth++;
|
|
2894
|
+
return;
|
|
2895
|
+
}
|
|
2896
|
+
if (isCallTo(node, "watch")) {
|
|
2897
|
+
const cb = node.arguments?.[1];
|
|
2898
|
+
if (cb?.type === "ArrowFunctionExpression" || cb?.type === "FunctionExpression" || cb?.type === "FunctionDeclaration") watchCallbackNodes.add(cb);
|
|
2899
|
+
}
|
|
2310
2900
|
},
|
|
2311
2901
|
"CallExpression:exit"(node) {
|
|
2312
|
-
if (isCallTo(node, "onMount") || isCallTo(node, "effect")) safeDepth--;
|
|
2902
|
+
if (isCallTo(node, "onMount") || isCallTo(node, "onUnmount") || isCallTo(node, "onCleanup") || isCallTo(node, "effect") || isCallTo(node, "renderEffect") || isCallTo(node, "requestAnimationFrame")) safeDepth--;
|
|
2313
2903
|
},
|
|
2314
2904
|
IfStatement(node) {
|
|
2315
|
-
|
|
2316
|
-
if (
|
|
2905
|
+
if (testIsTypeofGuard(node.test)) typeofGuardDepth++;
|
|
2906
|
+
else if (isEarlyReturnTypeofGuard(node)) noteEarlyReturnGuardVisit();
|
|
2317
2907
|
},
|
|
2318
2908
|
"IfStatement:exit"(node) {
|
|
2319
|
-
|
|
2320
|
-
|
|
2909
|
+
if (testIsTypeofGuard(node.test)) typeofGuardDepth--;
|
|
2910
|
+
},
|
|
2911
|
+
ConditionalExpression(node) {
|
|
2912
|
+
if (testIsTypeofGuard(node.test)) typeofGuardDepth++;
|
|
2913
|
+
},
|
|
2914
|
+
"ConditionalExpression:exit"(node) {
|
|
2915
|
+
if (testIsTypeofGuard(node.test)) typeofGuardDepth--;
|
|
2916
|
+
},
|
|
2917
|
+
UnaryExpression(node) {
|
|
2918
|
+
if (node.operator === "typeof") inTypeofExpr++;
|
|
2919
|
+
},
|
|
2920
|
+
"UnaryExpression:exit"(node) {
|
|
2921
|
+
if (node.operator === "typeof") inTypeofExpr--;
|
|
2922
|
+
},
|
|
2923
|
+
TSTypeAnnotation(_n) {
|
|
2924
|
+
inTsTypePos++;
|
|
2925
|
+
},
|
|
2926
|
+
"TSTypeAnnotation:exit"(_n) {
|
|
2927
|
+
inTsTypePos--;
|
|
2928
|
+
},
|
|
2929
|
+
TSTypeReference(_n) {
|
|
2930
|
+
inTsTypePos++;
|
|
2931
|
+
},
|
|
2932
|
+
"TSTypeReference:exit"(_n) {
|
|
2933
|
+
inTsTypePos--;
|
|
2321
2934
|
},
|
|
2322
|
-
|
|
2323
|
-
|
|
2935
|
+
TSTypeAliasDeclaration(_n) {
|
|
2936
|
+
inTsTypePos++;
|
|
2937
|
+
},
|
|
2938
|
+
"TSTypeAliasDeclaration:exit"(_n) {
|
|
2939
|
+
inTsTypePos--;
|
|
2940
|
+
},
|
|
2941
|
+
TSInterfaceDeclaration(_n) {
|
|
2942
|
+
inTsTypePos++;
|
|
2943
|
+
},
|
|
2944
|
+
"TSInterfaceDeclaration:exit"(_n) {
|
|
2945
|
+
inTsTypePos--;
|
|
2946
|
+
},
|
|
2947
|
+
TSTypeParameter(_n) {
|
|
2948
|
+
inTsTypePos++;
|
|
2949
|
+
},
|
|
2950
|
+
"TSTypeParameter:exit"(_n) {
|
|
2951
|
+
inTsTypePos--;
|
|
2952
|
+
},
|
|
2953
|
+
MemberExpression(node) {
|
|
2954
|
+
if (!node.computed && node.property?.type === "Identifier") skipPropertyNodes.add(node.property);
|
|
2955
|
+
},
|
|
2956
|
+
Property(node) {
|
|
2957
|
+
if (!node.computed && node.key?.type === "Identifier") skipPropertyNodes.add(node.key);
|
|
2958
|
+
},
|
|
2959
|
+
ImportSpecifier(node) {
|
|
2960
|
+
if (node.imported?.type === "Identifier") skipPropertyNodes.add(node.imported);
|
|
2961
|
+
if (node.local?.type === "Identifier" && node.local !== node.imported) skipPropertyNodes.add(node.local);
|
|
2962
|
+
if (node.local?.type === "Identifier" && BROWSER_GLOBALS.has(node.local.name)) importShadowedNames.add(node.local.name);
|
|
2963
|
+
},
|
|
2964
|
+
ImportDefaultSpecifier(node) {
|
|
2965
|
+
if (node.local?.type === "Identifier") {
|
|
2966
|
+
skipPropertyNodes.add(node.local);
|
|
2967
|
+
if (BROWSER_GLOBALS.has(node.local.name)) importShadowedNames.add(node.local.name);
|
|
2968
|
+
}
|
|
2969
|
+
},
|
|
2970
|
+
ImportNamespaceSpecifier(node) {
|
|
2971
|
+
if (node.local?.type === "Identifier") {
|
|
2972
|
+
skipPropertyNodes.add(node.local);
|
|
2973
|
+
if (BROWSER_GLOBALS.has(node.local.name)) importShadowedNames.add(node.local.name);
|
|
2974
|
+
}
|
|
2975
|
+
},
|
|
2976
|
+
Identifier(node) {
|
|
2977
|
+
if (safeDepth > 0 || typeofGuardDepth > 0 || inTypeofExpr > 0 || inTsTypePos > 0) return;
|
|
2978
|
+
if (skipPropertyNodes.has(node)) return;
|
|
2324
2979
|
if (!BROWSER_GLOBALS.has(node.name)) return;
|
|
2325
|
-
if (
|
|
2326
|
-
if (
|
|
2327
|
-
if (parent?.type === "MemberExpression" && parent.property === node && !parent.computed) return;
|
|
2980
|
+
if (isNameShadowed(node.name)) return;
|
|
2981
|
+
if (importShadowedNames.has(node.name)) return;
|
|
2328
2982
|
context.report({
|
|
2329
2983
|
message: `Browser global \`${node.name}\` used outside \`onMount\`/\`effect\`/typeof guard — this will fail during SSR. Wrap in \`onMount(() => { ... })\`.`,
|
|
2330
2984
|
span: getSpan(node)
|
|
@@ -2392,6 +3046,7 @@ const noDuplicateStoreId = {
|
|
|
2392
3046
|
fixable: false
|
|
2393
3047
|
},
|
|
2394
3048
|
create(context) {
|
|
3049
|
+
if (isTestFile(context.getFilePath())) return {};
|
|
2395
3050
|
const storeIds = /* @__PURE__ */ new Map();
|
|
2396
3051
|
return { CallExpression(node) {
|
|
2397
3052
|
if (!isCallTo(node, "defineStore")) return;
|
|
@@ -2417,25 +3072,30 @@ const noMutateStoreState = {
|
|
|
2417
3072
|
meta: {
|
|
2418
3073
|
id: "pyreon/no-mutate-store-state",
|
|
2419
3074
|
category: "store",
|
|
2420
|
-
description: "Warn when
|
|
3075
|
+
description: "Warn when calling .set() on store signals from a component or hook — use store actions instead.",
|
|
2421
3076
|
severity: "warn",
|
|
2422
3077
|
fixable: false
|
|
2423
3078
|
},
|
|
2424
3079
|
create(context) {
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
3080
|
+
const ctx = createComponentContextTracker();
|
|
3081
|
+
return {
|
|
3082
|
+
...ctx.callbacks,
|
|
3083
|
+
CallExpression(node) {
|
|
3084
|
+
if (!ctx.isInComponentOrHook()) return;
|
|
3085
|
+
const callee = node.callee;
|
|
3086
|
+
if (!callee || callee.type !== "MemberExpression") return;
|
|
3087
|
+
if (callee.property?.type !== "Identifier" || callee.property.name !== "set") return;
|
|
3088
|
+
const obj = callee.object;
|
|
3089
|
+
if (!obj || obj.type !== "MemberExpression") return;
|
|
3090
|
+
const outerObj = obj.object;
|
|
3091
|
+
if (!outerObj || outerObj.type !== "Identifier") return;
|
|
3092
|
+
const name = outerObj.name;
|
|
3093
|
+
if (name.toLowerCase().includes("store")) context.report({
|
|
3094
|
+
message: `Direct \`.set()\` on store state \`${name}\` — use store actions to mutate state for better traceability.`,
|
|
3095
|
+
span: getSpan(node)
|
|
3096
|
+
});
|
|
3097
|
+
}
|
|
3098
|
+
};
|
|
2439
3099
|
}
|
|
2440
3100
|
};
|
|
2441
3101
|
|
|
@@ -2486,44 +3146,27 @@ const noDynamicStyled = {
|
|
|
2486
3146
|
meta: {
|
|
2487
3147
|
id: "pyreon/no-dynamic-styled",
|
|
2488
3148
|
category: "styling",
|
|
2489
|
-
description: "Warn when styled() is called inside a
|
|
3149
|
+
description: "Warn when styled() is called inside a component or hook — it creates new CSS on every render.",
|
|
2490
3150
|
severity: "warn",
|
|
2491
3151
|
fixable: false
|
|
2492
3152
|
},
|
|
2493
3153
|
create(context) {
|
|
2494
|
-
|
|
3154
|
+
const ctx = createComponentContextTracker();
|
|
2495
3155
|
return {
|
|
2496
|
-
|
|
2497
|
-
functionDepth++;
|
|
2498
|
-
},
|
|
2499
|
-
"FunctionDeclaration:exit"() {
|
|
2500
|
-
functionDepth--;
|
|
2501
|
-
},
|
|
2502
|
-
FunctionExpression() {
|
|
2503
|
-
functionDepth++;
|
|
2504
|
-
},
|
|
2505
|
-
"FunctionExpression:exit"() {
|
|
2506
|
-
functionDepth--;
|
|
2507
|
-
},
|
|
2508
|
-
ArrowFunctionExpression() {
|
|
2509
|
-
functionDepth++;
|
|
2510
|
-
},
|
|
2511
|
-
"ArrowFunctionExpression:exit"() {
|
|
2512
|
-
functionDepth--;
|
|
2513
|
-
},
|
|
3156
|
+
...ctx.callbacks,
|
|
2514
3157
|
CallExpression(node) {
|
|
2515
|
-
if (
|
|
3158
|
+
if (!ctx.isInComponentOrHook()) return;
|
|
2516
3159
|
if (isCallTo(node, "styled")) context.report({
|
|
2517
|
-
message: "`styled()` inside a
|
|
3160
|
+
message: "`styled()` inside a component or hook — this creates new CSS rules on every render. Move `styled()` to module scope.",
|
|
2518
3161
|
span: getSpan(node)
|
|
2519
3162
|
});
|
|
2520
3163
|
},
|
|
2521
3164
|
TaggedTemplateExpression(node) {
|
|
2522
|
-
if (
|
|
3165
|
+
if (!ctx.isInComponentOrHook()) return;
|
|
2523
3166
|
const tag = node.tag;
|
|
2524
3167
|
if (!tag) return;
|
|
2525
3168
|
if (tag.type === "CallExpression" && isCallTo(tag, "styled")) context.report({
|
|
2526
|
-
message: "`styled()` tagged template inside a
|
|
3169
|
+
message: "`styled()` tagged template inside a component or hook — this creates new CSS rules on every render. Move to module scope.",
|
|
2527
3170
|
span: getSpan(node)
|
|
2528
3171
|
});
|
|
2529
3172
|
}
|
|
@@ -2556,6 +3199,7 @@ const noInlineStyleObject = {
|
|
|
2556
3199
|
|
|
2557
3200
|
//#endregion
|
|
2558
3201
|
//#region src/rules/styling/no-theme-outside-provider.ts
|
|
3202
|
+
const HOOK_NAME = /^use[A-Z]/;
|
|
2559
3203
|
const noThemeOutsideProvider = {
|
|
2560
3204
|
meta: {
|
|
2561
3205
|
id: "pyreon/no-theme-outside-provider",
|
|
@@ -2566,22 +3210,47 @@ const noThemeOutsideProvider = {
|
|
|
2566
3210
|
},
|
|
2567
3211
|
create(context) {
|
|
2568
3212
|
let hasProviderImport = false;
|
|
3213
|
+
let hookDepth = 0;
|
|
2569
3214
|
const themeCalls = [];
|
|
3215
|
+
function declaratorIsHook(node) {
|
|
3216
|
+
if (node?.id?.type !== "Identifier") return false;
|
|
3217
|
+
const init = node.init;
|
|
3218
|
+
if (init?.type !== "ArrowFunctionExpression" && init?.type !== "FunctionExpression") return false;
|
|
3219
|
+
return HOOK_NAME.test(node.id.name);
|
|
3220
|
+
}
|
|
2570
3221
|
return {
|
|
3222
|
+
FunctionDeclaration(node) {
|
|
3223
|
+
if (node.id?.name && HOOK_NAME.test(node.id.name)) hookDepth++;
|
|
3224
|
+
},
|
|
3225
|
+
"FunctionDeclaration:exit"(node) {
|
|
3226
|
+
if (node.id?.name && HOOK_NAME.test(node.id.name)) hookDepth--;
|
|
3227
|
+
},
|
|
3228
|
+
VariableDeclarator(node) {
|
|
3229
|
+
if (declaratorIsHook(node)) hookDepth++;
|
|
3230
|
+
},
|
|
3231
|
+
"VariableDeclarator:exit"(node) {
|
|
3232
|
+
if (declaratorIsHook(node)) hookDepth--;
|
|
3233
|
+
},
|
|
2571
3234
|
ImportDeclaration(node) {
|
|
2572
3235
|
const info = extractImportInfo(node);
|
|
2573
3236
|
if (!info) return;
|
|
2574
3237
|
if (info.specifiers.some((s) => s.imported === "PyreonUI" || s.imported === "ThemeProvider")) hasProviderImport = true;
|
|
2575
3238
|
},
|
|
2576
3239
|
CallExpression(node) {
|
|
2577
|
-
if (isCallTo(node, "useTheme")) themeCalls.push({
|
|
3240
|
+
if (isCallTo(node, "useTheme")) themeCalls.push({
|
|
3241
|
+
span: getSpan(node),
|
|
3242
|
+
insideHook: hookDepth > 0
|
|
3243
|
+
});
|
|
2578
3244
|
},
|
|
2579
3245
|
"Program:exit"() {
|
|
2580
3246
|
if (hasProviderImport) return;
|
|
2581
|
-
for (const call of themeCalls)
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
3247
|
+
for (const call of themeCalls) {
|
|
3248
|
+
if (call.insideHook) continue;
|
|
3249
|
+
context.report({
|
|
3250
|
+
message: "`useTheme()` without a `PyreonUI` or `ThemeProvider` import — the theme context may not be available.",
|
|
3251
|
+
span: call.span
|
|
3252
|
+
});
|
|
3253
|
+
}
|
|
2585
3254
|
}
|
|
2586
3255
|
};
|
|
2587
3256
|
}
|
|
@@ -2660,6 +3329,7 @@ const allRules = [
|
|
|
2660
3329
|
devGuardWarnings,
|
|
2661
3330
|
noErrorWithoutPrefix,
|
|
2662
3331
|
noProcessDevGate,
|
|
3332
|
+
requireBrowserSmokeTest,
|
|
2663
3333
|
noStoreOutsideProvider,
|
|
2664
3334
|
noMutateStoreState,
|
|
2665
3335
|
noDuplicateStoreId,
|
|
@@ -2690,11 +3360,17 @@ function buildRecommended() {
|
|
|
2690
3360
|
for (const rule of allRules) rules[rule.meta.id] = rule.meta.severity;
|
|
2691
3361
|
return { rules };
|
|
2692
3362
|
}
|
|
3363
|
+
function severityOf(entry) {
|
|
3364
|
+
return Array.isArray(entry) ? entry[0] : entry;
|
|
3365
|
+
}
|
|
2693
3366
|
/** Build a config where every warn is promoted to error. */
|
|
2694
3367
|
function buildStrict() {
|
|
2695
3368
|
const base = buildRecommended();
|
|
2696
3369
|
const rules = {};
|
|
2697
|
-
for (const [id,
|
|
3370
|
+
for (const [id, entry] of Object.entries(base.rules)) {
|
|
3371
|
+
const sev = severityOf(entry);
|
|
3372
|
+
rules[id] = sev === "warn" ? "error" : sev;
|
|
3373
|
+
}
|
|
2698
3374
|
return { rules };
|
|
2699
3375
|
}
|
|
2700
3376
|
/** Build app config — recommended but disable library-only rules. */
|
|
@@ -2704,7 +3380,8 @@ function buildApp() {
|
|
|
2704
3380
|
"pyreon/dev-guard-warnings": "off",
|
|
2705
3381
|
"pyreon/no-error-without-prefix": "off",
|
|
2706
3382
|
"pyreon/no-circular-import": "off",
|
|
2707
|
-
"pyreon/no-cross-layer-import": "off"
|
|
3383
|
+
"pyreon/no-cross-layer-import": "off",
|
|
3384
|
+
"pyreon/require-browser-smoke-test": "off"
|
|
2708
3385
|
} };
|
|
2709
3386
|
}
|
|
2710
3387
|
/** Build lib config — strict + all architecture rules as error. */
|
|
@@ -2715,7 +3392,8 @@ function buildLib() {
|
|
|
2715
3392
|
"pyreon/no-cross-layer-import": "error",
|
|
2716
3393
|
"pyreon/dev-guard-warnings": "error",
|
|
2717
3394
|
"pyreon/no-error-without-prefix": "error",
|
|
2718
|
-
"pyreon/no-process-dev-gate": "error"
|
|
3395
|
+
"pyreon/no-process-dev-gate": "error",
|
|
3396
|
+
"pyreon/require-browser-smoke-test": "error"
|
|
2719
3397
|
} };
|
|
2720
3398
|
}
|
|
2721
3399
|
const presetBuilders = {
|
|
@@ -2773,8 +3451,46 @@ function hasJsExtension(filePath) {
|
|
|
2773
3451
|
return JS_EXTENSIONS.has(ext);
|
|
2774
3452
|
}
|
|
2775
3453
|
|
|
3454
|
+
//#endregion
|
|
3455
|
+
//#region src/utils/validate-options.ts
|
|
3456
|
+
function validateRuleOptions(rule, options) {
|
|
3457
|
+
const schema = rule.meta.schema;
|
|
3458
|
+
const errors = [];
|
|
3459
|
+
const warnings = [];
|
|
3460
|
+
if (!schema) return {
|
|
3461
|
+
errors,
|
|
3462
|
+
warnings
|
|
3463
|
+
};
|
|
3464
|
+
for (const [key, value] of Object.entries(options)) {
|
|
3465
|
+
const expected = schema[key];
|
|
3466
|
+
if (expected === void 0) {
|
|
3467
|
+
warnings.push(`[${rule.meta.id}] unknown option "${key}" — allowed options: ${Object.keys(schema).join(", ") || "(none)"}`);
|
|
3468
|
+
continue;
|
|
3469
|
+
}
|
|
3470
|
+
if (!matchesType(value, expected)) errors.push(`[${rule.meta.id}] option "${key}" must be ${expected}, got ${describe(value)}`);
|
|
3471
|
+
}
|
|
3472
|
+
return {
|
|
3473
|
+
errors,
|
|
3474
|
+
warnings
|
|
3475
|
+
};
|
|
3476
|
+
}
|
|
3477
|
+
function matchesType(value, type) {
|
|
3478
|
+
switch (type) {
|
|
3479
|
+
case "string": return typeof value === "string";
|
|
3480
|
+
case "string[]": return Array.isArray(value) && value.every((x) => typeof x === "string");
|
|
3481
|
+
case "number": return typeof value === "number" && Number.isFinite(value);
|
|
3482
|
+
case "boolean": return typeof value === "boolean";
|
|
3483
|
+
}
|
|
3484
|
+
}
|
|
3485
|
+
function describe(value) {
|
|
3486
|
+
if (value === null) return "null";
|
|
3487
|
+
if (Array.isArray(value)) return `Array<${[...new Set(value.map((x) => x === null ? "null" : typeof x))].join(" | ") || "empty"}>`;
|
|
3488
|
+
return typeof value;
|
|
3489
|
+
}
|
|
3490
|
+
|
|
2776
3491
|
//#endregion
|
|
2777
3492
|
//#region src/runner.ts
|
|
3493
|
+
const VALIDATION_CACHE = /* @__PURE__ */ new Map();
|
|
2778
3494
|
function getExtension(filePath) {
|
|
2779
3495
|
const lastDot = filePath.lastIndexOf(".");
|
|
2780
3496
|
return lastDot === -1 ? "" : filePath.slice(lastDot);
|
|
@@ -2784,7 +3500,7 @@ function getLang(ext) {
|
|
|
2784
3500
|
if (ext === ".ts" || ext === ".mts") return "ts";
|
|
2785
3501
|
return "js";
|
|
2786
3502
|
}
|
|
2787
|
-
function createRuleContext(rule, severity, diagnostics, lineIndex, sourceText, filePath) {
|
|
3503
|
+
function createRuleContext(rule, severity, options, diagnostics, lineIndex, sourceText, filePath) {
|
|
2788
3504
|
return {
|
|
2789
3505
|
report(partial) {
|
|
2790
3506
|
diagnostics.push({
|
|
@@ -2801,6 +3517,9 @@ function createRuleContext(rule, severity, diagnostics, lineIndex, sourceText, f
|
|
|
2801
3517
|
},
|
|
2802
3518
|
getFilePath() {
|
|
2803
3519
|
return filePath;
|
|
3520
|
+
},
|
|
3521
|
+
getOptions() {
|
|
3522
|
+
return options;
|
|
2804
3523
|
}
|
|
2805
3524
|
};
|
|
2806
3525
|
}
|
|
@@ -2830,7 +3549,7 @@ function mergeCallbacks(allCallbacks) {
|
|
|
2830
3549
|
* for (const d of result.diagnostics) console.log(d.message)
|
|
2831
3550
|
* ```
|
|
2832
3551
|
*/
|
|
2833
|
-
function lintFile(filePath, sourceText, rules, config, cache) {
|
|
3552
|
+
function lintFile(filePath, sourceText, rules, config, cache, configDiagnosticsSink) {
|
|
2834
3553
|
const ext = getExtension(filePath);
|
|
2835
3554
|
if (!JS_EXTENSIONS.has(ext)) return {
|
|
2836
3555
|
filePath,
|
|
@@ -2863,20 +3582,50 @@ function lintFile(filePath, sourceText, rules, config, cache) {
|
|
|
2863
3582
|
const diagnostics = [];
|
|
2864
3583
|
const allCallbacks = [];
|
|
2865
3584
|
for (const rule of rules) {
|
|
2866
|
-
const
|
|
2867
|
-
if (
|
|
2868
|
-
const
|
|
3585
|
+
const entry = config.rules[rule.meta.id];
|
|
3586
|
+
if (entry === void 0) continue;
|
|
3587
|
+
const [severity, options] = Array.isArray(entry) ? [entry[0], entry[1] ?? {}] : [entry, {}];
|
|
3588
|
+
if (severity === "off") continue;
|
|
3589
|
+
const cacheKey = `${rule.meta.id}::${JSON.stringify(options)}`;
|
|
3590
|
+
let cached = VALIDATION_CACHE.get(cacheKey);
|
|
3591
|
+
if (!cached) {
|
|
3592
|
+
const { errors, warnings } = validateRuleOptions(rule, options);
|
|
3593
|
+
const configDiags = [];
|
|
3594
|
+
for (const message of warnings) configDiags.push({
|
|
3595
|
+
ruleId: rule.meta.id,
|
|
3596
|
+
severity: "warn",
|
|
3597
|
+
message
|
|
3598
|
+
});
|
|
3599
|
+
for (const message of errors) configDiags.push({
|
|
3600
|
+
ruleId: rule.meta.id,
|
|
3601
|
+
severity: "error",
|
|
3602
|
+
message
|
|
3603
|
+
});
|
|
3604
|
+
cached = {
|
|
3605
|
+
ok: errors.length === 0,
|
|
3606
|
+
diagnostics: configDiags
|
|
3607
|
+
};
|
|
3608
|
+
VALIDATION_CACHE.set(cacheKey, cached);
|
|
3609
|
+
}
|
|
3610
|
+
if (cached.diagnostics.length > 0) if (configDiagnosticsSink) {
|
|
3611
|
+
for (const d of cached.diagnostics) if (!configDiagnosticsSink.some((x) => x.ruleId === d.ruleId && x.message === d.message)) configDiagnosticsSink.push(d);
|
|
3612
|
+
} else for (const d of cached.diagnostics) (d.severity === "error" ? console.error : console.warn)(`[pyreon-lint] ${d.message}`);
|
|
3613
|
+
if (!cached.ok) continue;
|
|
3614
|
+
const ctx = createRuleContext(rule, severity, options, diagnostics, lineIndex, sourceText, filePath);
|
|
2869
3615
|
allCallbacks.push(rule.create(ctx));
|
|
2870
3616
|
}
|
|
2871
3617
|
new Visitor(mergeCallbacks(allCallbacks)).visit(program);
|
|
2872
3618
|
const lines = sourceText.split("\n");
|
|
3619
|
+
const SUPPRESS_RE = /^\/\/\s*pyreon-lint-(?:ignore|disable-next-line)(?:\s+(\S+))?\s*$/;
|
|
2873
3620
|
const filtered = diagnostics.filter((d) => {
|
|
2874
3621
|
const prevLineIdx = d.loc.line - 2;
|
|
2875
3622
|
if (prevLineIdx < 0) return true;
|
|
2876
|
-
const prevLine = lines[prevLineIdx]?.trim();
|
|
2877
|
-
|
|
2878
|
-
|
|
2879
|
-
|
|
3623
|
+
const prevLine = lines[prevLineIdx]?.trim() ?? "";
|
|
3624
|
+
const match = SUPPRESS_RE.exec(prevLine);
|
|
3625
|
+
if (!match) return true;
|
|
3626
|
+
const ruleId = match[1];
|
|
3627
|
+
if (!ruleId) return false;
|
|
3628
|
+
return ruleId !== d.ruleId;
|
|
2880
3629
|
});
|
|
2881
3630
|
filtered.sort((a, b) => a.span.start - b.span.start);
|
|
2882
3631
|
return {
|
|
@@ -2954,8 +3703,17 @@ function buildConfig(options) {
|
|
|
2954
3703
|
const cwd = resolve(".");
|
|
2955
3704
|
const fileConfig = options.config ? loadConfigFromPath(options.config) : loadConfig(cwd);
|
|
2956
3705
|
const config = getPreset(options.preset ?? fileConfig?.preset ?? "recommended");
|
|
2957
|
-
if (fileConfig?.rules) for (const [id,
|
|
3706
|
+
if (fileConfig?.rules) for (const [id, entry] of Object.entries(fileConfig.rules)) config.rules[id] = entry;
|
|
2958
3707
|
if (options.ruleOverrides) for (const [id, severity] of Object.entries(options.ruleOverrides)) config.rules[id] = severity;
|
|
3708
|
+
if (options.ruleOptionsOverrides) for (const [id, optionOverrides] of Object.entries(options.ruleOptionsOverrides)) {
|
|
3709
|
+
const existing = config.rules[id];
|
|
3710
|
+
const [currentSeverity, currentOptions] = Array.isArray(existing) ? [existing[0], existing[1] ?? {}] : [existing ?? "off", {}];
|
|
3711
|
+
if (currentSeverity === "off") continue;
|
|
3712
|
+
config.rules[id] = [currentSeverity, {
|
|
3713
|
+
...currentOptions,
|
|
3714
|
+
...optionOverrides
|
|
3715
|
+
}];
|
|
3716
|
+
}
|
|
2959
3717
|
return {
|
|
2960
3718
|
config,
|
|
2961
3719
|
include: fileConfig?.include,
|
|
@@ -3005,11 +3763,13 @@ function lint(options) {
|
|
|
3005
3763
|
const { config, include, exclude, isIgnored } = buildConfig(options);
|
|
3006
3764
|
const cache = new AstCache();
|
|
3007
3765
|
const files = gatherFiles(options.paths, isIgnored, include, exclude);
|
|
3766
|
+
const configDiagnostics = [];
|
|
3008
3767
|
const results = {
|
|
3009
3768
|
files: [],
|
|
3010
3769
|
totalErrors: 0,
|
|
3011
3770
|
totalWarnings: 0,
|
|
3012
|
-
totalInfos: 0
|
|
3771
|
+
totalInfos: 0,
|
|
3772
|
+
configDiagnostics
|
|
3013
3773
|
};
|
|
3014
3774
|
for (const filePath of files) {
|
|
3015
3775
|
let source;
|
|
@@ -3018,7 +3778,7 @@ function lint(options) {
|
|
|
3018
3778
|
} catch {
|
|
3019
3779
|
continue;
|
|
3020
3780
|
}
|
|
3021
|
-
const fileResult = lintFile(filePath, source, allRules, config, cache);
|
|
3781
|
+
const fileResult = lintFile(filePath, source, allRules, config, cache, configDiagnostics);
|
|
3022
3782
|
if (options.fix) applyFixesToFile(fileResult, source);
|
|
3023
3783
|
if (options.quiet) fileResult.diagnostics = fileResult.diagnostics.filter((d) => d.severity === "error");
|
|
3024
3784
|
countDiagnostics(fileResult, results);
|
|
@@ -3063,7 +3823,7 @@ function listRules() {
|
|
|
3063
3823
|
* @module
|
|
3064
3824
|
*/
|
|
3065
3825
|
const cache = new AstCache();
|
|
3066
|
-
|
|
3826
|
+
let config = getPreset("recommended");
|
|
3067
3827
|
function toLspDiagnostics(diagnostics) {
|
|
3068
3828
|
return diagnostics.map((d) => ({
|
|
3069
3829
|
range: {
|
|
@@ -3300,6 +4060,7 @@ function watchAndLint(options) {
|
|
|
3300
4060
|
const cache = new AstCache();
|
|
3301
4061
|
const config = getPreset(options.preset ?? "recommended");
|
|
3302
4062
|
applyOverrides(config, options.ruleOverrides);
|
|
4063
|
+
applyOptionsOverrides(config, options.ruleOptionsOverrides);
|
|
3303
4064
|
const isIgnored = createIgnoreFilter(resolve("."), options.ignore);
|
|
3304
4065
|
const pending = /* @__PURE__ */ new Map();
|
|
3305
4066
|
console.log(`\x1b[2m[pyreon-lint] Watching for changes...\x1b[0m\n`);
|
|
@@ -3326,6 +4087,18 @@ function applyOverrides(config, overrides) {
|
|
|
3326
4087
|
if (!overrides) return;
|
|
3327
4088
|
for (const [id, severity] of Object.entries(overrides)) config.rules[id] = severity;
|
|
3328
4089
|
}
|
|
4090
|
+
function applyOptionsOverrides(config, overrides) {
|
|
4091
|
+
if (!overrides) return;
|
|
4092
|
+
for (const [id, opts] of Object.entries(overrides)) {
|
|
4093
|
+
const existing = config.rules[id];
|
|
4094
|
+
const [severity, current] = Array.isArray(existing) ? [existing[0], existing[1] ?? {}] : [existing ?? "off", {}];
|
|
4095
|
+
if (severity === "off") continue;
|
|
4096
|
+
config.rules[id] = [severity, {
|
|
4097
|
+
...current,
|
|
4098
|
+
...opts
|
|
4099
|
+
}];
|
|
4100
|
+
}
|
|
4101
|
+
}
|
|
3329
4102
|
function relintFile(filePath, config, cache, format) {
|
|
3330
4103
|
let source;
|
|
3331
4104
|
try {
|
|
@@ -3339,7 +4112,8 @@ function relintFile(filePath, config, cache, format) {
|
|
|
3339
4112
|
files: [fileResult],
|
|
3340
4113
|
totalErrors: 0,
|
|
3341
4114
|
totalWarnings: 0,
|
|
3342
|
-
totalInfos: 0
|
|
4115
|
+
totalInfos: 0,
|
|
4116
|
+
configDiagnostics: []
|
|
3343
4117
|
};
|
|
3344
4118
|
for (const d of fileResult.diagnostics) if (d.severity === "error") result.totalErrors++;
|
|
3345
4119
|
else if (d.severity === "warn") result.totalWarnings++;
|
|
@@ -3361,7 +4135,8 @@ function printUsage() {
|
|
|
3361
4135
|
--format <fmt> Output: text (default), json, compact
|
|
3362
4136
|
--quiet Only show errors
|
|
3363
4137
|
--list List all available rules
|
|
3364
|
-
--rule <id>=<sev>
|
|
4138
|
+
--rule <id>=<sev> Override rule severity (e.g. --rule pyreon/no-window-in-ssr=off)
|
|
4139
|
+
--rule-options <id>=<json> Override rule options (e.g. --rule-options pyreon/no-window-in-ssr='{"exemptPaths":["src/foundation/"]}')
|
|
3365
4140
|
--config <path> Config file path
|
|
3366
4141
|
--ignore <path> Ignore file path
|
|
3367
4142
|
--watch Watch mode — re-lint on file changes
|
|
@@ -3408,6 +4183,7 @@ function parseArgs(argv) {
|
|
|
3408
4183
|
configPath: void 0,
|
|
3409
4184
|
ignorePath: void 0,
|
|
3410
4185
|
ruleOverrides: {},
|
|
4186
|
+
ruleOptionsOverrides: {},
|
|
3411
4187
|
paths: []
|
|
3412
4188
|
};
|
|
3413
4189
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -3444,6 +4220,10 @@ function parseValueFlag(arg, nextArg, result) {
|
|
|
3444
4220
|
parseRuleOverride(nextArg, result.ruleOverrides);
|
|
3445
4221
|
return 1;
|
|
3446
4222
|
}
|
|
4223
|
+
if (arg === "--rule-options") {
|
|
4224
|
+
parseRuleOptionsOverride(nextArg, result.ruleOptionsOverrides);
|
|
4225
|
+
return 1;
|
|
4226
|
+
}
|
|
3447
4227
|
if (arg) result.paths.push(arg);
|
|
3448
4228
|
return 0;
|
|
3449
4229
|
}
|
|
@@ -3454,6 +4234,21 @@ function parseRuleOverride(val, overrides) {
|
|
|
3454
4234
|
const ruleId = val.slice(0, eqIdx);
|
|
3455
4235
|
overrides[ruleId] = val.slice(eqIdx + 1);
|
|
3456
4236
|
}
|
|
4237
|
+
/** Exported for testing only. */
|
|
4238
|
+
function parseRuleOptionsOverride(val, overrides) {
|
|
4239
|
+
if (!val) return;
|
|
4240
|
+
const eqIdx = val.indexOf("=");
|
|
4241
|
+
if (eqIdx === -1) return;
|
|
4242
|
+
const ruleId = val.slice(0, eqIdx);
|
|
4243
|
+
const json = val.slice(eqIdx + 1);
|
|
4244
|
+
try {
|
|
4245
|
+
const parsed = JSON.parse(json);
|
|
4246
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) overrides[ruleId] = parsed;
|
|
4247
|
+
else console.error(`[pyreon-lint] --rule-options ${ruleId}: expected JSON object, got ${typeof parsed}`);
|
|
4248
|
+
} catch (err) {
|
|
4249
|
+
console.error(`[pyreon-lint] --rule-options ${ruleId}: invalid JSON — ${err.message}`);
|
|
4250
|
+
}
|
|
4251
|
+
}
|
|
3457
4252
|
function main() {
|
|
3458
4253
|
const args = parseArgs(process.argv.slice(2));
|
|
3459
4254
|
if (args.showHelp) {
|
|
@@ -3480,6 +4275,7 @@ function main() {
|
|
|
3480
4275
|
fix: args.fix,
|
|
3481
4276
|
quiet: args.quiet,
|
|
3482
4277
|
ruleOverrides: args.ruleOverrides,
|
|
4278
|
+
ruleOptionsOverrides: args.ruleOptionsOverrides,
|
|
3483
4279
|
config: args.configPath,
|
|
3484
4280
|
ignore: args.ignorePath,
|
|
3485
4281
|
format: args.format
|
|
@@ -3492,6 +4288,7 @@ function main() {
|
|
|
3492
4288
|
fix: args.fix,
|
|
3493
4289
|
quiet: args.quiet,
|
|
3494
4290
|
ruleOverrides: args.ruleOverrides,
|
|
4291
|
+
ruleOptionsOverrides: args.ruleOptionsOverrides,
|
|
3495
4292
|
config: args.configPath,
|
|
3496
4293
|
ignore: args.ignorePath
|
|
3497
4294
|
});
|
|
@@ -3503,7 +4300,8 @@ function main() {
|
|
|
3503
4300
|
}
|
|
3504
4301
|
if (result.totalErrors > 0) process.exit(1);
|
|
3505
4302
|
}
|
|
3506
|
-
main();
|
|
4303
|
+
if (import.meta.main === true) main();
|
|
3507
4304
|
|
|
3508
4305
|
//#endregion
|
|
4306
|
+
export { parseRuleOptionsOverride };
|
|
3509
4307
|
//# sourceMappingURL=cli.js.map
|