@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/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 cached = VALIDATION_CACHE.get(cacheKey);
4071
- if (!cached) {
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
- cached = {
4326
+ validation = {
4085
4327
  ok: errors.length === 0,
4086
4328
  diagnostics: configDiags
4087
4329
  };
4088
- VALIDATION_CACHE.set(cacheKey, cached);
4330
+ VALIDATION_CACHE.set(cacheKey, validation);
4089
4331
  }
4090
- if (cached.diagnostics.length > 0) if (configDiagnosticsSink) {
4091
- for (const d of cached.diagnostics) if (!configDiagnosticsSink.some((x) => x.ruleId === d.ruleId && x.message === d.message)) configDiagnosticsSink.push(d);
4092
- } else for (const d of cached.diagnostics) (d.severity === "error" ? console.error : console.warn)(`[pyreon-lint] ${d.message}`);
4093
- if (!cached.ok) continue;
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 cached = VALIDATION_CACHE.get(cacheKey);
4083
- if (!cached) {
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
- cached = {
4338
+ validation = {
4097
4339
  ok: errors.length === 0,
4098
4340
  diagnostics: configDiags
4099
4341
  };
4100
- VALIDATION_CACHE.set(cacheKey, cached);
4342
+ VALIDATION_CACHE.set(cacheKey, validation);
4101
4343
  }
4102
- if (cached.diagnostics.length > 0) if (configDiagnosticsSink) {
4103
- for (const d of cached.diagnostics) if (!configDiagnosticsSink.some((x) => x.ruleId === d.ruleId && x.message === d.message)) configDiagnosticsSink.push(d);
4104
- } else for (const d of cached.diagnostics) (d.severity === "error" ? console.error : console.warn)(`[pyreon-lint] ${d.message}`);
4105
- if (!cached.ok) continue;
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
  }
@@ -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.15.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": "./lib/cli.js"
16
+ "pyreon-lint": "./bin/pyreon-lint.js"
17
17
  },
18
18
  "files": [
19
+ "bin",
19
20
  "lib",
20
21
  "!lib/**/*.map",
21
22
  "src",