@pyreon/lint 0.15.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/pyreon-lint.js +2 -0
- package/lib/analysis/cli.js.html +1 -1
- package/lib/analysis/index.js.html +1 -1
- package/lib/cli.js +251 -9
- package/lib/index.js +251 -9
- package/lib/types/index.d.ts +1 -1
- package/package.json +3 -2
- package/src/rules/index.ts +15 -1
- package/src/rules/reactivity/storage-signal-v-forwarding.ts +184 -0
- package/src/rules/ssg/index.ts +3 -0
- package/src/rules/ssg/invalid-loader-export.ts +84 -0
- package/src/rules/ssg/missing-get-static-paths.ts +103 -0
- package/src/rules/ssg/revalidate-not-pure-literal.ts +69 -0
- package/src/runner.ts +8 -8
- package/src/tests/runner.test.ts +8 -5
- package/src/tests/ssg-rules.test.ts +211 -0
- package/src/tests/storage-signal-v-forwarding.test.ts +224 -0
- package/src/types.ts +1 -0
- package/src/utils/validate-options.ts +1 -1
package/lib/cli.js
CHANGED
|
@@ -2937,6 +2937,99 @@ const preferComputed = {
|
|
|
2937
2937
|
}
|
|
2938
2938
|
};
|
|
2939
2939
|
|
|
2940
|
+
//#endregion
|
|
2941
|
+
//#region src/rules/reactivity/storage-signal-v-forwarding.ts
|
|
2942
|
+
function isDirectDelegation(rhs) {
|
|
2943
|
+
if (!rhs) return false;
|
|
2944
|
+
if (rhs.type === "MemberExpression" && rhs.property?.name === "direct") return true;
|
|
2945
|
+
if (rhs.type === "ArrowFunctionExpression" || rhs.type === "FunctionExpression") {
|
|
2946
|
+
const body = rhs.body;
|
|
2947
|
+
if (!body) return false;
|
|
2948
|
+
if (body.type !== "BlockStatement") return isDirectCall(body);
|
|
2949
|
+
const stmts = body.body;
|
|
2950
|
+
if (!stmts || stmts.length !== 1) return false;
|
|
2951
|
+
const stmt = stmts[0];
|
|
2952
|
+
if (stmt.type !== "ReturnStatement") return false;
|
|
2953
|
+
return isDirectCall(stmt.argument);
|
|
2954
|
+
}
|
|
2955
|
+
return false;
|
|
2956
|
+
}
|
|
2957
|
+
function isDirectCall(expr) {
|
|
2958
|
+
if (!expr || expr.type !== "CallExpression") return false;
|
|
2959
|
+
const callee = expr.callee;
|
|
2960
|
+
return Boolean(callee && callee.type === "MemberExpression" && callee.property?.name === "direct");
|
|
2961
|
+
}
|
|
2962
|
+
function getStringLiteralValue(node) {
|
|
2963
|
+
if (!node) return null;
|
|
2964
|
+
if (node.type === "Literal" && typeof node.value === "string") return node.value;
|
|
2965
|
+
if (node.type === "StringLiteral" && typeof node.value === "string") return node.value;
|
|
2966
|
+
return null;
|
|
2967
|
+
}
|
|
2968
|
+
const storageSignalVForwarding = {
|
|
2969
|
+
meta: {
|
|
2970
|
+
id: "pyreon/storage-signal-v-forwarding",
|
|
2971
|
+
category: "reactivity",
|
|
2972
|
+
description: "Signal-wrapper callables delegating `.direct` to a base signal must also forward the internal `_v` field. Without forwarding, the compiler-emitted `_bindText` fast path reads `undefined` and renders empty text post-hydration.",
|
|
2973
|
+
severity: "error",
|
|
2974
|
+
fixable: false
|
|
2975
|
+
},
|
|
2976
|
+
create(context) {
|
|
2977
|
+
const stack = [];
|
|
2978
|
+
const enter = () => {
|
|
2979
|
+
stack.push({
|
|
2980
|
+
directAssigns: /* @__PURE__ */ new Map(),
|
|
2981
|
+
vForwards: /* @__PURE__ */ new Set()
|
|
2982
|
+
});
|
|
2983
|
+
};
|
|
2984
|
+
const exit = () => {
|
|
2985
|
+
const scope = stack.pop();
|
|
2986
|
+
if (!scope) return;
|
|
2987
|
+
for (const [name, node] of scope.directAssigns) {
|
|
2988
|
+
if (scope.vForwards.has(name)) continue;
|
|
2989
|
+
context.report({
|
|
2990
|
+
message: `Signal wrapper '${name}' delegates \`.direct\` to a base signal but does not forward \`_v\`. The compiler-emitted \`_bindText\` fast path reads \`${name}._v\` directly — without forwarding, the binding writes \`''\` on initial render AND every subscriber notification, even after \`.set()\` calls. Add: \`Object.defineProperty(${name}, '_v', { get: () => sig._v, configurable: true })\` in the same scope. Reference: \`packages/fundamentals/storage/src/local.ts:createStorageSignal\` for the canonical shape.`,
|
|
2991
|
+
span: getSpan(node)
|
|
2992
|
+
});
|
|
2993
|
+
}
|
|
2994
|
+
};
|
|
2995
|
+
return {
|
|
2996
|
+
Program: enter,
|
|
2997
|
+
"Program:exit": exit,
|
|
2998
|
+
FunctionDeclaration: enter,
|
|
2999
|
+
"FunctionDeclaration:exit": exit,
|
|
3000
|
+
FunctionExpression: enter,
|
|
3001
|
+
"FunctionExpression:exit": exit,
|
|
3002
|
+
ArrowFunctionExpression: enter,
|
|
3003
|
+
"ArrowFunctionExpression:exit": exit,
|
|
3004
|
+
AssignmentExpression(node) {
|
|
3005
|
+
const scope = stack[stack.length - 1];
|
|
3006
|
+
if (!scope) return;
|
|
3007
|
+
const left = node.left;
|
|
3008
|
+
if (left?.type !== "MemberExpression") return;
|
|
3009
|
+
if (left.object?.type !== "Identifier") return;
|
|
3010
|
+
const objName = left.object.name;
|
|
3011
|
+
const propName = left.property?.name ?? getStringLiteralValue(left.property);
|
|
3012
|
+
if (propName === "direct" && isDirectDelegation(node.right)) scope.directAssigns.set(objName, node);
|
|
3013
|
+
else if (propName === "_v") scope.vForwards.add(objName);
|
|
3014
|
+
},
|
|
3015
|
+
CallExpression(node) {
|
|
3016
|
+
const scope = stack[stack.length - 1];
|
|
3017
|
+
if (!scope) return;
|
|
3018
|
+
const callee = node.callee;
|
|
3019
|
+
if (!callee || callee.type !== "MemberExpression") return;
|
|
3020
|
+
if (callee.object?.type !== "Identifier" || callee.object.name !== "Object") return;
|
|
3021
|
+
if (callee.property?.name !== "defineProperty") return;
|
|
3022
|
+
const args = node.arguments;
|
|
3023
|
+
if (!args || args.length < 2) return;
|
|
3024
|
+
const target = args[0];
|
|
3025
|
+
if (target?.type !== "Identifier") return;
|
|
3026
|
+
if (getStringLiteralValue(args[1]) !== "_v") return;
|
|
3027
|
+
scope.vForwards.add(target.name);
|
|
3028
|
+
}
|
|
3029
|
+
};
|
|
3030
|
+
}
|
|
3031
|
+
};
|
|
3032
|
+
|
|
2940
3033
|
//#endregion
|
|
2941
3034
|
//#region src/rules/router/no-href-navigation.ts
|
|
2942
3035
|
const EXTERNAL_PREFIXES = [
|
|
@@ -3178,6 +3271,151 @@ function isPathComparison(node) {
|
|
|
3178
3271
|
return false;
|
|
3179
3272
|
}
|
|
3180
3273
|
|
|
3274
|
+
//#endregion
|
|
3275
|
+
//#region src/rules/ssg/invalid-loader-export.ts
|
|
3276
|
+
const ROUTES_PATH_RE$2 = /[/\\]routes[/\\]/;
|
|
3277
|
+
function isLikelyCallable(node) {
|
|
3278
|
+
if (!node) return false;
|
|
3279
|
+
if (node.type === "ArrowFunctionExpression") return true;
|
|
3280
|
+
if (node.type === "FunctionExpression") return true;
|
|
3281
|
+
if (node.type === "Identifier") return true;
|
|
3282
|
+
if (node.type === "CallExpression") return true;
|
|
3283
|
+
if (node.type === "MemberExpression") return true;
|
|
3284
|
+
if (node.type === "TSAsExpression" || node.type === "TSSatisfiesExpression") return isLikelyCallable(node.expression);
|
|
3285
|
+
return false;
|
|
3286
|
+
}
|
|
3287
|
+
const invalidLoaderExport = {
|
|
3288
|
+
meta: {
|
|
3289
|
+
id: "pyreon/invalid-loader-export",
|
|
3290
|
+
category: "ssg",
|
|
3291
|
+
description: "`export const loader` must be a function — non-callable exports crash the SSR runtime with `loader is not a function`.",
|
|
3292
|
+
severity: "error",
|
|
3293
|
+
fixable: false
|
|
3294
|
+
},
|
|
3295
|
+
create(context) {
|
|
3296
|
+
const filePath = context.getFilePath();
|
|
3297
|
+
if (!ROUTES_PATH_RE$2.test(filePath)) return {};
|
|
3298
|
+
return { ExportNamedDeclaration(node) {
|
|
3299
|
+
const decl = node.declaration;
|
|
3300
|
+
if (!decl) return;
|
|
3301
|
+
if (decl.type !== "VariableDeclaration") return;
|
|
3302
|
+
for (const declarator of decl.declarations ?? []) {
|
|
3303
|
+
if (declarator.type !== "VariableDeclarator") continue;
|
|
3304
|
+
const id = declarator.id;
|
|
3305
|
+
if (id?.type !== "Identifier" || id.name !== "loader") continue;
|
|
3306
|
+
const init = declarator.init;
|
|
3307
|
+
if (!init) continue;
|
|
3308
|
+
if (isLikelyCallable(init)) continue;
|
|
3309
|
+
context.report({
|
|
3310
|
+
message: "`export const loader` must be a function (arrow, function expression, or identifier reference). Got a non-callable expression — the SSR runtime will crash with `TypeError: loader is not a function`. If you meant to export static data, use `export const meta = { ... }` instead.",
|
|
3311
|
+
span: getSpan(init)
|
|
3312
|
+
});
|
|
3313
|
+
}
|
|
3314
|
+
} };
|
|
3315
|
+
}
|
|
3316
|
+
};
|
|
3317
|
+
|
|
3318
|
+
//#endregion
|
|
3319
|
+
//#region src/rules/ssg/missing-get-static-paths.ts
|
|
3320
|
+
const ROUTES_PATH_RE$1 = /[/\\]routes[/\\]/;
|
|
3321
|
+
const API_PATH_RE = /[/\\]routes[/\\]api[/\\]/;
|
|
3322
|
+
const DYNAMIC_FILENAME_RE = /\[.+\]\.(tsx?|jsx?)$/;
|
|
3323
|
+
const SPECIAL_ROUTE_RE = /[/\\]_(layout|error|loading|404|not-found)\./;
|
|
3324
|
+
const missingGetStaticPaths = {
|
|
3325
|
+
meta: {
|
|
3326
|
+
id: "pyreon/missing-get-static-paths",
|
|
3327
|
+
category: "ssg",
|
|
3328
|
+
description: "Dynamic route files (`[id].tsx`, `[...slug].tsx`) should export `getStaticPaths` — under `mode: \"ssg\"` the SSG plugin silently skips routes without it.",
|
|
3329
|
+
severity: "warn",
|
|
3330
|
+
fixable: false
|
|
3331
|
+
},
|
|
3332
|
+
create(context) {
|
|
3333
|
+
const filePath = context.getFilePath();
|
|
3334
|
+
if (!ROUTES_PATH_RE$1.test(filePath)) return {};
|
|
3335
|
+
if (API_PATH_RE.test(filePath)) return {};
|
|
3336
|
+
if (!DYNAMIC_FILENAME_RE.test(filePath)) return {};
|
|
3337
|
+
if (SPECIAL_ROUTE_RE.test(filePath)) return {};
|
|
3338
|
+
let hasGetStaticPaths = false;
|
|
3339
|
+
let hasDefaultExport = false;
|
|
3340
|
+
let programSpan = null;
|
|
3341
|
+
return {
|
|
3342
|
+
Program(node) {
|
|
3343
|
+
programSpan = {
|
|
3344
|
+
start: node.start ?? 0,
|
|
3345
|
+
end: node.end ?? 0
|
|
3346
|
+
};
|
|
3347
|
+
},
|
|
3348
|
+
ExportNamedDeclaration(node) {
|
|
3349
|
+
const decl = node.declaration;
|
|
3350
|
+
if (!decl) return;
|
|
3351
|
+
if (decl.type === "VariableDeclaration") for (const declarator of decl.declarations ?? []) {
|
|
3352
|
+
if (declarator.type !== "VariableDeclarator") continue;
|
|
3353
|
+
const id = declarator.id;
|
|
3354
|
+
if (id?.type === "Identifier" && id.name === "getStaticPaths") hasGetStaticPaths = true;
|
|
3355
|
+
}
|
|
3356
|
+
else if (decl.type === "FunctionDeclaration") {
|
|
3357
|
+
if (decl.id?.name === "getStaticPaths") hasGetStaticPaths = true;
|
|
3358
|
+
}
|
|
3359
|
+
},
|
|
3360
|
+
ExportDefaultDeclaration() {
|
|
3361
|
+
hasDefaultExport = true;
|
|
3362
|
+
},
|
|
3363
|
+
"Program:exit"() {
|
|
3364
|
+
if (!hasDefaultExport) return;
|
|
3365
|
+
if (hasGetStaticPaths || !programSpan) return;
|
|
3366
|
+
context.report({
|
|
3367
|
+
message: "Dynamic route file is missing `export const getStaticPaths` — under `mode: \"ssg\"` the SSG plugin silently skips this route, so the dist won't contain prerendered HTML. Either add `export const getStaticPaths = () => [{ params: { ... } }, ...]` enumerating the concrete values, OR declare the route as runtime-only by switching to `mode: \"ssr\"` / `mode: \"isr\"`.",
|
|
3368
|
+
span: {
|
|
3369
|
+
start: programSpan.start,
|
|
3370
|
+
end: Math.min(programSpan.start + 1, programSpan.end)
|
|
3371
|
+
}
|
|
3372
|
+
});
|
|
3373
|
+
}
|
|
3374
|
+
};
|
|
3375
|
+
}
|
|
3376
|
+
};
|
|
3377
|
+
|
|
3378
|
+
//#endregion
|
|
3379
|
+
//#region src/rules/ssg/revalidate-not-pure-literal.ts
|
|
3380
|
+
const ROUTES_PATH_RE = /[/\\]routes[/\\]/;
|
|
3381
|
+
function isLiteralOk(node) {
|
|
3382
|
+
if (!node) return false;
|
|
3383
|
+
if (node.type === "Literal" && typeof node.value === "number") return true;
|
|
3384
|
+
if (node.type === "NumericLiteral" && typeof node.value === "number") return true;
|
|
3385
|
+
if (node.type === "Literal" && node.value === false) return true;
|
|
3386
|
+
if (node.type === "BooleanLiteral" && node.value === false) return true;
|
|
3387
|
+
return false;
|
|
3388
|
+
}
|
|
3389
|
+
const revalidateNotPureLiteral = {
|
|
3390
|
+
meta: {
|
|
3391
|
+
id: "pyreon/revalidate-not-pure-literal",
|
|
3392
|
+
category: "ssg",
|
|
3393
|
+
description: "`export const revalidate = X` must be a numeric literal or `false` — non-literal forms are silently dropped from the build-time ISR manifest (PR I limitation).",
|
|
3394
|
+
severity: "error",
|
|
3395
|
+
fixable: false
|
|
3396
|
+
},
|
|
3397
|
+
create(context) {
|
|
3398
|
+
const filePath = context.getFilePath();
|
|
3399
|
+
if (!ROUTES_PATH_RE.test(filePath)) return {};
|
|
3400
|
+
return { ExportNamedDeclaration(node) {
|
|
3401
|
+
const decl = node.declaration;
|
|
3402
|
+
if (!decl || decl.type !== "VariableDeclaration") return;
|
|
3403
|
+
for (const declarator of decl.declarations ?? []) {
|
|
3404
|
+
if (declarator.type !== "VariableDeclarator") continue;
|
|
3405
|
+
const id = declarator.id;
|
|
3406
|
+
if (id?.type !== "Identifier" || id.name !== "revalidate") continue;
|
|
3407
|
+
const init = declarator.init;
|
|
3408
|
+
if (!init) continue;
|
|
3409
|
+
if (isLiteralOk(init)) continue;
|
|
3410
|
+
context.report({
|
|
3411
|
+
message: "`export const revalidate` must be a numeric literal (e.g. `60`, `3600`) or `false` — non-literal expressions (variable references, math, function calls, template literals) are silently dropped from the build-time ISR manifest. Inline the value: `export const revalidate = 60`.",
|
|
3412
|
+
span: getSpan(init)
|
|
3413
|
+
});
|
|
3414
|
+
}
|
|
3415
|
+
} };
|
|
3416
|
+
}
|
|
3417
|
+
};
|
|
3418
|
+
|
|
3181
3419
|
//#endregion
|
|
3182
3420
|
//#region src/rules/ssr/no-mismatch-risk.ts
|
|
3183
3421
|
const noMismatchRisk = {
|
|
@@ -3780,6 +4018,7 @@ const allRules = [
|
|
|
3780
4018
|
noEffectAssignment,
|
|
3781
4019
|
noSignalLeak,
|
|
3782
4020
|
noSignalCallWrite,
|
|
4021
|
+
storageSignalVForwarding,
|
|
3783
4022
|
noMapInJsx,
|
|
3784
4023
|
useByNotKey,
|
|
3785
4024
|
noClassName,
|
|
@@ -3829,7 +4068,10 @@ const allRules = [
|
|
|
3829
4068
|
noHrefNavigation,
|
|
3830
4069
|
noImperativeNavigateInRender,
|
|
3831
4070
|
noMissingFallback,
|
|
3832
|
-
preferUseIsActive
|
|
4071
|
+
preferUseIsActive,
|
|
4072
|
+
invalidLoaderExport,
|
|
4073
|
+
missingGetStaticPaths,
|
|
4074
|
+
revalidateNotPureLiteral
|
|
3833
4075
|
];
|
|
3834
4076
|
|
|
3835
4077
|
//#endregion
|
|
@@ -4067,8 +4309,8 @@ function lintFile(filePath, sourceText, rules, config, cache, configDiagnosticsS
|
|
|
4067
4309
|
const [severity, options] = Array.isArray(entry) ? [entry[0], entry[1] ?? {}] : [entry, {}];
|
|
4068
4310
|
if (severity === "off") continue;
|
|
4069
4311
|
const cacheKey = `${rule.meta.id}::${JSON.stringify(options)}`;
|
|
4070
|
-
let
|
|
4071
|
-
if (!
|
|
4312
|
+
let validation = VALIDATION_CACHE.get(cacheKey);
|
|
4313
|
+
if (!validation) {
|
|
4072
4314
|
const { errors, warnings } = validateRuleOptions(rule, options);
|
|
4073
4315
|
const configDiags = [];
|
|
4074
4316
|
for (const message of warnings) configDiags.push({
|
|
@@ -4081,16 +4323,16 @@ function lintFile(filePath, sourceText, rules, config, cache, configDiagnosticsS
|
|
|
4081
4323
|
severity: "error",
|
|
4082
4324
|
message
|
|
4083
4325
|
});
|
|
4084
|
-
|
|
4326
|
+
validation = {
|
|
4085
4327
|
ok: errors.length === 0,
|
|
4086
4328
|
diagnostics: configDiags
|
|
4087
4329
|
};
|
|
4088
|
-
VALIDATION_CACHE.set(cacheKey,
|
|
4330
|
+
VALIDATION_CACHE.set(cacheKey, validation);
|
|
4089
4331
|
}
|
|
4090
|
-
if (
|
|
4091
|
-
for (const d of
|
|
4092
|
-
} else for (const d of
|
|
4093
|
-
if (!
|
|
4332
|
+
if (validation.diagnostics.length > 0) if (configDiagnosticsSink) {
|
|
4333
|
+
for (const d of validation.diagnostics) if (!configDiagnosticsSink.some((x) => x.ruleId === d.ruleId && x.message === d.message)) configDiagnosticsSink.push(d);
|
|
4334
|
+
} else for (const d of validation.diagnostics) (d.severity === "error" ? console.error : console.warn)(`[pyreon-lint] ${d.message}`);
|
|
4335
|
+
if (!validation.ok) continue;
|
|
4094
4336
|
const ctx = createRuleContext(rule, severity, options, diagnostics, lineIndex, sourceText, filePath);
|
|
4095
4337
|
allCallbacks.push(rule.create(ctx));
|
|
4096
4338
|
}
|
package/lib/index.js
CHANGED
|
@@ -2949,6 +2949,99 @@ const preferComputed = {
|
|
|
2949
2949
|
}
|
|
2950
2950
|
};
|
|
2951
2951
|
|
|
2952
|
+
//#endregion
|
|
2953
|
+
//#region src/rules/reactivity/storage-signal-v-forwarding.ts
|
|
2954
|
+
function isDirectDelegation(rhs) {
|
|
2955
|
+
if (!rhs) return false;
|
|
2956
|
+
if (rhs.type === "MemberExpression" && rhs.property?.name === "direct") return true;
|
|
2957
|
+
if (rhs.type === "ArrowFunctionExpression" || rhs.type === "FunctionExpression") {
|
|
2958
|
+
const body = rhs.body;
|
|
2959
|
+
if (!body) return false;
|
|
2960
|
+
if (body.type !== "BlockStatement") return isDirectCall(body);
|
|
2961
|
+
const stmts = body.body;
|
|
2962
|
+
if (!stmts || stmts.length !== 1) return false;
|
|
2963
|
+
const stmt = stmts[0];
|
|
2964
|
+
if (stmt.type !== "ReturnStatement") return false;
|
|
2965
|
+
return isDirectCall(stmt.argument);
|
|
2966
|
+
}
|
|
2967
|
+
return false;
|
|
2968
|
+
}
|
|
2969
|
+
function isDirectCall(expr) {
|
|
2970
|
+
if (!expr || expr.type !== "CallExpression") return false;
|
|
2971
|
+
const callee = expr.callee;
|
|
2972
|
+
return Boolean(callee && callee.type === "MemberExpression" && callee.property?.name === "direct");
|
|
2973
|
+
}
|
|
2974
|
+
function getStringLiteralValue(node) {
|
|
2975
|
+
if (!node) return null;
|
|
2976
|
+
if (node.type === "Literal" && typeof node.value === "string") return node.value;
|
|
2977
|
+
if (node.type === "StringLiteral" && typeof node.value === "string") return node.value;
|
|
2978
|
+
return null;
|
|
2979
|
+
}
|
|
2980
|
+
const storageSignalVForwarding = {
|
|
2981
|
+
meta: {
|
|
2982
|
+
id: "pyreon/storage-signal-v-forwarding",
|
|
2983
|
+
category: "reactivity",
|
|
2984
|
+
description: "Signal-wrapper callables delegating `.direct` to a base signal must also forward the internal `_v` field. Without forwarding, the compiler-emitted `_bindText` fast path reads `undefined` and renders empty text post-hydration.",
|
|
2985
|
+
severity: "error",
|
|
2986
|
+
fixable: false
|
|
2987
|
+
},
|
|
2988
|
+
create(context) {
|
|
2989
|
+
const stack = [];
|
|
2990
|
+
const enter = () => {
|
|
2991
|
+
stack.push({
|
|
2992
|
+
directAssigns: /* @__PURE__ */ new Map(),
|
|
2993
|
+
vForwards: /* @__PURE__ */ new Set()
|
|
2994
|
+
});
|
|
2995
|
+
};
|
|
2996
|
+
const exit = () => {
|
|
2997
|
+
const scope = stack.pop();
|
|
2998
|
+
if (!scope) return;
|
|
2999
|
+
for (const [name, node] of scope.directAssigns) {
|
|
3000
|
+
if (scope.vForwards.has(name)) continue;
|
|
3001
|
+
context.report({
|
|
3002
|
+
message: `Signal wrapper '${name}' delegates \`.direct\` to a base signal but does not forward \`_v\`. The compiler-emitted \`_bindText\` fast path reads \`${name}._v\` directly — without forwarding, the binding writes \`''\` on initial render AND every subscriber notification, even after \`.set()\` calls. Add: \`Object.defineProperty(${name}, '_v', { get: () => sig._v, configurable: true })\` in the same scope. Reference: \`packages/fundamentals/storage/src/local.ts:createStorageSignal\` for the canonical shape.`,
|
|
3003
|
+
span: getSpan(node)
|
|
3004
|
+
});
|
|
3005
|
+
}
|
|
3006
|
+
};
|
|
3007
|
+
return {
|
|
3008
|
+
Program: enter,
|
|
3009
|
+
"Program:exit": exit,
|
|
3010
|
+
FunctionDeclaration: enter,
|
|
3011
|
+
"FunctionDeclaration:exit": exit,
|
|
3012
|
+
FunctionExpression: enter,
|
|
3013
|
+
"FunctionExpression:exit": exit,
|
|
3014
|
+
ArrowFunctionExpression: enter,
|
|
3015
|
+
"ArrowFunctionExpression:exit": exit,
|
|
3016
|
+
AssignmentExpression(node) {
|
|
3017
|
+
const scope = stack[stack.length - 1];
|
|
3018
|
+
if (!scope) return;
|
|
3019
|
+
const left = node.left;
|
|
3020
|
+
if (left?.type !== "MemberExpression") return;
|
|
3021
|
+
if (left.object?.type !== "Identifier") return;
|
|
3022
|
+
const objName = left.object.name;
|
|
3023
|
+
const propName = left.property?.name ?? getStringLiteralValue(left.property);
|
|
3024
|
+
if (propName === "direct" && isDirectDelegation(node.right)) scope.directAssigns.set(objName, node);
|
|
3025
|
+
else if (propName === "_v") scope.vForwards.add(objName);
|
|
3026
|
+
},
|
|
3027
|
+
CallExpression(node) {
|
|
3028
|
+
const scope = stack[stack.length - 1];
|
|
3029
|
+
if (!scope) return;
|
|
3030
|
+
const callee = node.callee;
|
|
3031
|
+
if (!callee || callee.type !== "MemberExpression") return;
|
|
3032
|
+
if (callee.object?.type !== "Identifier" || callee.object.name !== "Object") return;
|
|
3033
|
+
if (callee.property?.name !== "defineProperty") return;
|
|
3034
|
+
const args = node.arguments;
|
|
3035
|
+
if (!args || args.length < 2) return;
|
|
3036
|
+
const target = args[0];
|
|
3037
|
+
if (target?.type !== "Identifier") return;
|
|
3038
|
+
if (getStringLiteralValue(args[1]) !== "_v") return;
|
|
3039
|
+
scope.vForwards.add(target.name);
|
|
3040
|
+
}
|
|
3041
|
+
};
|
|
3042
|
+
}
|
|
3043
|
+
};
|
|
3044
|
+
|
|
2952
3045
|
//#endregion
|
|
2953
3046
|
//#region src/rules/router/no-href-navigation.ts
|
|
2954
3047
|
const EXTERNAL_PREFIXES = [
|
|
@@ -3190,6 +3283,151 @@ function isPathComparison(node) {
|
|
|
3190
3283
|
return false;
|
|
3191
3284
|
}
|
|
3192
3285
|
|
|
3286
|
+
//#endregion
|
|
3287
|
+
//#region src/rules/ssg/invalid-loader-export.ts
|
|
3288
|
+
const ROUTES_PATH_RE$2 = /[/\\]routes[/\\]/;
|
|
3289
|
+
function isLikelyCallable(node) {
|
|
3290
|
+
if (!node) return false;
|
|
3291
|
+
if (node.type === "ArrowFunctionExpression") return true;
|
|
3292
|
+
if (node.type === "FunctionExpression") return true;
|
|
3293
|
+
if (node.type === "Identifier") return true;
|
|
3294
|
+
if (node.type === "CallExpression") return true;
|
|
3295
|
+
if (node.type === "MemberExpression") return true;
|
|
3296
|
+
if (node.type === "TSAsExpression" || node.type === "TSSatisfiesExpression") return isLikelyCallable(node.expression);
|
|
3297
|
+
return false;
|
|
3298
|
+
}
|
|
3299
|
+
const invalidLoaderExport = {
|
|
3300
|
+
meta: {
|
|
3301
|
+
id: "pyreon/invalid-loader-export",
|
|
3302
|
+
category: "ssg",
|
|
3303
|
+
description: "`export const loader` must be a function — non-callable exports crash the SSR runtime with `loader is not a function`.",
|
|
3304
|
+
severity: "error",
|
|
3305
|
+
fixable: false
|
|
3306
|
+
},
|
|
3307
|
+
create(context) {
|
|
3308
|
+
const filePath = context.getFilePath();
|
|
3309
|
+
if (!ROUTES_PATH_RE$2.test(filePath)) return {};
|
|
3310
|
+
return { ExportNamedDeclaration(node) {
|
|
3311
|
+
const decl = node.declaration;
|
|
3312
|
+
if (!decl) return;
|
|
3313
|
+
if (decl.type !== "VariableDeclaration") return;
|
|
3314
|
+
for (const declarator of decl.declarations ?? []) {
|
|
3315
|
+
if (declarator.type !== "VariableDeclarator") continue;
|
|
3316
|
+
const id = declarator.id;
|
|
3317
|
+
if (id?.type !== "Identifier" || id.name !== "loader") continue;
|
|
3318
|
+
const init = declarator.init;
|
|
3319
|
+
if (!init) continue;
|
|
3320
|
+
if (isLikelyCallable(init)) continue;
|
|
3321
|
+
context.report({
|
|
3322
|
+
message: "`export const loader` must be a function (arrow, function expression, or identifier reference). Got a non-callable expression — the SSR runtime will crash with `TypeError: loader is not a function`. If you meant to export static data, use `export const meta = { ... }` instead.",
|
|
3323
|
+
span: getSpan(init)
|
|
3324
|
+
});
|
|
3325
|
+
}
|
|
3326
|
+
} };
|
|
3327
|
+
}
|
|
3328
|
+
};
|
|
3329
|
+
|
|
3330
|
+
//#endregion
|
|
3331
|
+
//#region src/rules/ssg/missing-get-static-paths.ts
|
|
3332
|
+
const ROUTES_PATH_RE$1 = /[/\\]routes[/\\]/;
|
|
3333
|
+
const API_PATH_RE = /[/\\]routes[/\\]api[/\\]/;
|
|
3334
|
+
const DYNAMIC_FILENAME_RE = /\[.+\]\.(tsx?|jsx?)$/;
|
|
3335
|
+
const SPECIAL_ROUTE_RE = /[/\\]_(layout|error|loading|404|not-found)\./;
|
|
3336
|
+
const missingGetStaticPaths = {
|
|
3337
|
+
meta: {
|
|
3338
|
+
id: "pyreon/missing-get-static-paths",
|
|
3339
|
+
category: "ssg",
|
|
3340
|
+
description: "Dynamic route files (`[id].tsx`, `[...slug].tsx`) should export `getStaticPaths` — under `mode: \"ssg\"` the SSG plugin silently skips routes without it.",
|
|
3341
|
+
severity: "warn",
|
|
3342
|
+
fixable: false
|
|
3343
|
+
},
|
|
3344
|
+
create(context) {
|
|
3345
|
+
const filePath = context.getFilePath();
|
|
3346
|
+
if (!ROUTES_PATH_RE$1.test(filePath)) return {};
|
|
3347
|
+
if (API_PATH_RE.test(filePath)) return {};
|
|
3348
|
+
if (!DYNAMIC_FILENAME_RE.test(filePath)) return {};
|
|
3349
|
+
if (SPECIAL_ROUTE_RE.test(filePath)) return {};
|
|
3350
|
+
let hasGetStaticPaths = false;
|
|
3351
|
+
let hasDefaultExport = false;
|
|
3352
|
+
let programSpan = null;
|
|
3353
|
+
return {
|
|
3354
|
+
Program(node) {
|
|
3355
|
+
programSpan = {
|
|
3356
|
+
start: node.start ?? 0,
|
|
3357
|
+
end: node.end ?? 0
|
|
3358
|
+
};
|
|
3359
|
+
},
|
|
3360
|
+
ExportNamedDeclaration(node) {
|
|
3361
|
+
const decl = node.declaration;
|
|
3362
|
+
if (!decl) return;
|
|
3363
|
+
if (decl.type === "VariableDeclaration") for (const declarator of decl.declarations ?? []) {
|
|
3364
|
+
if (declarator.type !== "VariableDeclarator") continue;
|
|
3365
|
+
const id = declarator.id;
|
|
3366
|
+
if (id?.type === "Identifier" && id.name === "getStaticPaths") hasGetStaticPaths = true;
|
|
3367
|
+
}
|
|
3368
|
+
else if (decl.type === "FunctionDeclaration") {
|
|
3369
|
+
if (decl.id?.name === "getStaticPaths") hasGetStaticPaths = true;
|
|
3370
|
+
}
|
|
3371
|
+
},
|
|
3372
|
+
ExportDefaultDeclaration() {
|
|
3373
|
+
hasDefaultExport = true;
|
|
3374
|
+
},
|
|
3375
|
+
"Program:exit"() {
|
|
3376
|
+
if (!hasDefaultExport) return;
|
|
3377
|
+
if (hasGetStaticPaths || !programSpan) return;
|
|
3378
|
+
context.report({
|
|
3379
|
+
message: "Dynamic route file is missing `export const getStaticPaths` — under `mode: \"ssg\"` the SSG plugin silently skips this route, so the dist won't contain prerendered HTML. Either add `export const getStaticPaths = () => [{ params: { ... } }, ...]` enumerating the concrete values, OR declare the route as runtime-only by switching to `mode: \"ssr\"` / `mode: \"isr\"`.",
|
|
3380
|
+
span: {
|
|
3381
|
+
start: programSpan.start,
|
|
3382
|
+
end: Math.min(programSpan.start + 1, programSpan.end)
|
|
3383
|
+
}
|
|
3384
|
+
});
|
|
3385
|
+
}
|
|
3386
|
+
};
|
|
3387
|
+
}
|
|
3388
|
+
};
|
|
3389
|
+
|
|
3390
|
+
//#endregion
|
|
3391
|
+
//#region src/rules/ssg/revalidate-not-pure-literal.ts
|
|
3392
|
+
const ROUTES_PATH_RE = /[/\\]routes[/\\]/;
|
|
3393
|
+
function isLiteralOk(node) {
|
|
3394
|
+
if (!node) return false;
|
|
3395
|
+
if (node.type === "Literal" && typeof node.value === "number") return true;
|
|
3396
|
+
if (node.type === "NumericLiteral" && typeof node.value === "number") return true;
|
|
3397
|
+
if (node.type === "Literal" && node.value === false) return true;
|
|
3398
|
+
if (node.type === "BooleanLiteral" && node.value === false) return true;
|
|
3399
|
+
return false;
|
|
3400
|
+
}
|
|
3401
|
+
const revalidateNotPureLiteral = {
|
|
3402
|
+
meta: {
|
|
3403
|
+
id: "pyreon/revalidate-not-pure-literal",
|
|
3404
|
+
category: "ssg",
|
|
3405
|
+
description: "`export const revalidate = X` must be a numeric literal or `false` — non-literal forms are silently dropped from the build-time ISR manifest (PR I limitation).",
|
|
3406
|
+
severity: "error",
|
|
3407
|
+
fixable: false
|
|
3408
|
+
},
|
|
3409
|
+
create(context) {
|
|
3410
|
+
const filePath = context.getFilePath();
|
|
3411
|
+
if (!ROUTES_PATH_RE.test(filePath)) return {};
|
|
3412
|
+
return { ExportNamedDeclaration(node) {
|
|
3413
|
+
const decl = node.declaration;
|
|
3414
|
+
if (!decl || decl.type !== "VariableDeclaration") return;
|
|
3415
|
+
for (const declarator of decl.declarations ?? []) {
|
|
3416
|
+
if (declarator.type !== "VariableDeclarator") continue;
|
|
3417
|
+
const id = declarator.id;
|
|
3418
|
+
if (id?.type !== "Identifier" || id.name !== "revalidate") continue;
|
|
3419
|
+
const init = declarator.init;
|
|
3420
|
+
if (!init) continue;
|
|
3421
|
+
if (isLiteralOk(init)) continue;
|
|
3422
|
+
context.report({
|
|
3423
|
+
message: "`export const revalidate` must be a numeric literal (e.g. `60`, `3600`) or `false` — non-literal expressions (variable references, math, function calls, template literals) are silently dropped from the build-time ISR manifest. Inline the value: `export const revalidate = 60`.",
|
|
3424
|
+
span: getSpan(init)
|
|
3425
|
+
});
|
|
3426
|
+
}
|
|
3427
|
+
} };
|
|
3428
|
+
}
|
|
3429
|
+
};
|
|
3430
|
+
|
|
3193
3431
|
//#endregion
|
|
3194
3432
|
//#region src/rules/ssr/no-mismatch-risk.ts
|
|
3195
3433
|
const noMismatchRisk = {
|
|
@@ -3792,6 +4030,7 @@ const allRules = [
|
|
|
3792
4030
|
noEffectAssignment,
|
|
3793
4031
|
noSignalLeak,
|
|
3794
4032
|
noSignalCallWrite,
|
|
4033
|
+
storageSignalVForwarding,
|
|
3795
4034
|
noMapInJsx,
|
|
3796
4035
|
useByNotKey,
|
|
3797
4036
|
noClassName,
|
|
@@ -3841,7 +4080,10 @@ const allRules = [
|
|
|
3841
4080
|
noHrefNavigation,
|
|
3842
4081
|
noImperativeNavigateInRender,
|
|
3843
4082
|
noMissingFallback,
|
|
3844
|
-
preferUseIsActive
|
|
4083
|
+
preferUseIsActive,
|
|
4084
|
+
invalidLoaderExport,
|
|
4085
|
+
missingGetStaticPaths,
|
|
4086
|
+
revalidateNotPureLiteral
|
|
3845
4087
|
];
|
|
3846
4088
|
|
|
3847
4089
|
//#endregion
|
|
@@ -4079,8 +4321,8 @@ function lintFile(filePath, sourceText, rules, config, cache, configDiagnosticsS
|
|
|
4079
4321
|
const [severity, options] = Array.isArray(entry) ? [entry[0], entry[1] ?? {}] : [entry, {}];
|
|
4080
4322
|
if (severity === "off") continue;
|
|
4081
4323
|
const cacheKey = `${rule.meta.id}::${JSON.stringify(options)}`;
|
|
4082
|
-
let
|
|
4083
|
-
if (!
|
|
4324
|
+
let validation = VALIDATION_CACHE.get(cacheKey);
|
|
4325
|
+
if (!validation) {
|
|
4084
4326
|
const { errors, warnings } = validateRuleOptions(rule, options);
|
|
4085
4327
|
const configDiags = [];
|
|
4086
4328
|
for (const message of warnings) configDiags.push({
|
|
@@ -4093,16 +4335,16 @@ function lintFile(filePath, sourceText, rules, config, cache, configDiagnosticsS
|
|
|
4093
4335
|
severity: "error",
|
|
4094
4336
|
message
|
|
4095
4337
|
});
|
|
4096
|
-
|
|
4338
|
+
validation = {
|
|
4097
4339
|
ok: errors.length === 0,
|
|
4098
4340
|
diagnostics: configDiags
|
|
4099
4341
|
};
|
|
4100
|
-
VALIDATION_CACHE.set(cacheKey,
|
|
4342
|
+
VALIDATION_CACHE.set(cacheKey, validation);
|
|
4101
4343
|
}
|
|
4102
|
-
if (
|
|
4103
|
-
for (const d of
|
|
4104
|
-
} else for (const d of
|
|
4105
|
-
if (!
|
|
4344
|
+
if (validation.diagnostics.length > 0) if (configDiagnosticsSink) {
|
|
4345
|
+
for (const d of validation.diagnostics) if (!configDiagnosticsSink.some((x) => x.ruleId === d.ruleId && x.message === d.message)) configDiagnosticsSink.push(d);
|
|
4346
|
+
} else for (const d of validation.diagnostics) (d.severity === "error" ? console.error : console.warn)(`[pyreon-lint] ${d.message}`);
|
|
4347
|
+
if (!validation.ok) continue;
|
|
4106
4348
|
const ctx = createRuleContext(rule, severity, options, diagnostics, lineIndex, sourceText, filePath);
|
|
4107
4349
|
allCallbacks.push(rule.create(ctx));
|
|
4108
4350
|
}
|
package/lib/types/index.d.ts
CHANGED
|
@@ -20,7 +20,7 @@ interface Diagnostic {
|
|
|
20
20
|
loc: SourceLocation;
|
|
21
21
|
fix?: Fix | undefined;
|
|
22
22
|
}
|
|
23
|
-
type RuleCategory = 'reactivity' | 'jsx' | 'lifecycle' | 'performance' | 'ssr' | 'architecture' | 'store' | 'form' | 'styling' | 'hooks' | 'accessibility' | 'router';
|
|
23
|
+
type RuleCategory = 'reactivity' | 'jsx' | 'lifecycle' | 'performance' | 'ssr' | 'architecture' | 'store' | 'form' | 'styling' | 'hooks' | 'accessibility' | 'router' | 'ssg';
|
|
24
24
|
/**
|
|
25
25
|
* Declared type of an option slot. Minimal on purpose — sufficient for
|
|
26
26
|
* the exemption patterns we actually use. Extend when a rule needs more.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pyreon/lint",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "Pyreon-specific linter — 56 rules for signals, JSX, SSR, performance, router, and architecture",
|
|
5
5
|
"homepage": "https://github.com/pyreon/pyreon/tree/main/packages/lint#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -13,9 +13,10 @@
|
|
|
13
13
|
"directory": "packages/tools/lint"
|
|
14
14
|
},
|
|
15
15
|
"bin": {
|
|
16
|
-
"pyreon-lint": "./
|
|
16
|
+
"pyreon-lint": "./bin/pyreon-lint.js"
|
|
17
17
|
},
|
|
18
18
|
"files": [
|
|
19
|
+
"bin",
|
|
19
20
|
"lib",
|
|
20
21
|
"!lib/**/*.map",
|
|
21
22
|
"src",
|