Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,37 @@ describe("no-github-request-interpolated-route", () => {
});
});

it("invalid: fallback and ternary client aliases are recognized", () => {
cjsRuleTester.run("no-github-request-interpolated-route", noGithubRequestInterpolatedRouteRule, {
valid: [
`const client = options.targetGithubClient || github; client.request("GET /repos/{owner}/{repo}", { owner, repo });`,
`const client = options.targetGithubClient || otherClient; client.request(\`GET /repos/\${owner}/\${repo}\`, {});`,
],
invalid: [
{
code: "const fallbackClient = options.targetGithubClient || github; fallbackClient.request(`GET /repos/${owner}/${repo}`, { owner, repo });",
errors: [{ messageId: "interpolatedRoute", data: { kind: "template literal with interpolations", client: "fallbackClient" } }],
},
{
code: "const client = github || otherClient; client.request(`GET /repos/${owner}/${repo}`, {});",
errors: [{ messageId: "interpolatedRoute", data: { kind: "template literal with interpolations", client: "client" } }],
},
{
code: `const client = useTarget ? targetClient : github; client.request("GET /repos/" + owner + "/" + repo, {});`,
errors: [{ messageId: "interpolatedRoute", data: { kind: "string concatenation expression", client: "client" } }],
},
{
code: "const client = useTarget ? github : targetClient; client.request(`GET /repos/${owner}/${repo}`, {});",
errors: [{ messageId: "interpolatedRoute", data: { kind: "template literal with interpolations", client: "client" } }],
},
{
code: "const client = options.client ?? context.github; client.request(`GET /repos/${owner}/${repo}`, {});",
errors: [{ messageId: "interpolatedRoute", data: { kind: "template literal with interpolations", client: "client" } }],
},
],
});
});

it("invalid: const alias of getOctokit() result is flagged", () => {
cjsRuleTester.run("no-github-request-interpolated-route", noGithubRequestInterpolatedRouteRule, {
valid: [],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,9 +105,12 @@ function isContextGithubExpression(node: TSESTree.Node): boolean {
* - `getOctokit(...)` call results (bare or via known module objects, e.g.
* `github.getOctokit(...)` or `actions.getOctokit(...)`)
* - `context.github` member expression
* - Fallback (`||` / `??`) and ternary expressions with a known source branch
*/
function isOctokitSourceExpression(node: TSESTree.Node): boolean {
if (node.type === AST_NODE_TYPES.Identifier && OCTOKIT_CLIENT_NAMES.has(node.name)) return true;
if (node.type === AST_NODE_TYPES.LogicalExpression && (node.operator === "||" || node.operator === "??")) return isOctokitSourceExpression(node.left) || isOctokitSourceExpression(node.right);
if (node.type === AST_NODE_TYPES.ConditionalExpression) return isOctokitSourceExpression(node.consequent) || isOctokitSourceExpression(node.alternate);

if (node.type === AST_NODE_TYPES.CallExpression) {
const callee = node.callee;
Expand Down Expand Up @@ -137,7 +140,7 @@ export const noGithubRequestInterpolatedRouteRule = createRule({
description:
"Disallow template literals with interpolations or string concatenation as the route argument of Octokit.request() calls. " +
"Octokit clients are detected by well-known names (github, octokit, githubClient, octokitClient), " +
"identifiers initialized from getOctokit(...) call results, context.github, and simple const aliases of any of these. " +
"identifiers initialized from getOctokit(...) call results, context.github, and const aliases or fallback expressions containing any of these. " +
"Use the typed placeholder form for value interpolation, or thread a typed route string from the caller when the entire route is dynamic.",
},
schema: [],
Expand Down
27 changes: 27 additions & 0 deletions eslint-factory/src/rules/no-math-minmax-array-spread.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,33 @@ describe("no-math-minmax-array-spread", () => {
});
});

it("valid: const arrays bounded by literals, filter, or slice are accepted", () => {
cjsRuleTester.run("no-math-minmax-array-spread", noMathMinMaxArraySpreadRule, {
valid: [
`const times = [reset, retryAt].filter(value => Number.isFinite(value)); Math.max(...times);`,
`const times = [a, b]; Math.min(...times);`,
`const times = [a, b, c].slice(1).filter(Boolean); Math.max(...times);`,
`const times = [a, b]; if (times.length) Math.max(...times);`,
],
invalid: [],
});
});

it("invalid: unknown or mutable array bindings remain unbounded", () => {
cjsRuleTester.run("no-math-minmax-array-spread", noMathMinMaxArraySpreadRule, {
valid: [],
invalid: [
{ code: `const times = runs.map(run => run.duration); Math.max(...times);`, errors: [{ messageId: "noMathMinMaxArraySpread" }] },
{ code: `let times = [a, b]; times = runs; Math.max(...times);`, errors: [{ messageId: "noMathMinMaxArraySpread" }] },
{ code: `const values = [a, b]; values.push(...runtimeValues); Math.max(...values);`, errors: [{ messageId: "noMathMinMaxArraySpread" }] },
{ code: `const values = [a, b]; values.length = count; Math.max(...values);`, errors: [{ messageId: "noMathMinMaxArraySpread" }] },
{ code: `const values = [a, b]; consume(values); Math.max(...values);`, errors: [{ messageId: "noMathMinMaxArraySpread" }] },
{ code: `const times = [...runs, a]; Math.min(...times);`, errors: [{ messageId: "noMathMinMaxArraySpread" }] },
{ code: `function f(times) { return Math.min(...times); }`, errors: [{ messageId: "noMathMinMaxArraySpread" }] },
],
});
});

it("valid: a shadowed Math binding is ignored", () => {
cjsRuleTester.run("no-math-minmax-array-spread", noMathMinMaxArraySpreadRule, {
valid: [
Expand Down
47 changes: 43 additions & 4 deletions eslint-factory/src/rules/no-math-minmax-array-spread.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export const noMathMinMaxArraySpreadRule = createRule({
type: "problem",
docs: {
description:
"Disallow spreading a non-literal array into Math.min(...) / Math.max(...). Spreading a large array into call arguments can throw `RangeError: Maximum call stack size exceeded` once the array exceeds the engine argument limit, so arrays whose size depends on runtime data must be reduced instead.",
"Disallow spreading an array of unknown size into Math.min(...) / Math.max(...). Spreading a large array into call arguments can throw `RangeError: Maximum call stack size exceeded` once the array exceeds the engine argument limit, so arrays whose size depends on runtime data must be reduced instead.",
},
schema: [],
messages: {
Expand All @@ -28,6 +28,7 @@ export const noMathMinMaxArraySpreadRule = createRule({
create(context) {
const sourceCode = context.sourceCode;
type SourceCodeScope = ReturnType<typeof sourceCode.getScope>;
const BOUNDED_ARRAY_METHODS = new Set(["filter", "slice"]);

/**
* Checks whether a given identifier name is locally bound in the current scope chain,
Expand Down Expand Up @@ -60,11 +61,49 @@ export const noMathMinMaxArraySpreadRule = createRule({
}

/**
* Returns true when the spread argument has a size that is not statically bounded by
* the source itself. Inline array literals are always bounded, so they are excluded.
* Returns true unless a const binding starts with a fixed-size array literal
* and only passes through size-non-increasing array methods.
*/
function isBoundedArrayExpression(node: TSESTree.Node): boolean {
if (node.type === AST_NODE_TYPES.ArrayExpression) {
return node.elements.every(element => element?.type !== AST_NODE_TYPES.SpreadElement);
}
if (node.type === AST_NODE_TYPES.CallExpression && !node.optional && node.callee.type === AST_NODE_TYPES.MemberExpression && !node.callee.optional && !node.callee.computed) {
const { object, property } = node.callee;
return property.type === AST_NODE_TYPES.Identifier && BOUNDED_ARRAY_METHODS.has(property.name) && isBoundedArrayExpression(object);
}
return false;
}

function isUnboundedSpreadArgument(node: TSESTree.Node): boolean {
return node.type === AST_NODE_TYPES.Identifier || node.type === AST_NODE_TYPES.MemberExpression || node.type === AST_NODE_TYPES.CallExpression;
if (node.type === AST_NODE_TYPES.Identifier) {
let scope: SourceCodeScope | null = sourceCode.getScope(node);
while (scope) {
const variable = scope.set.get(node.name);
if (variable) {
if (variable.defs.length !== 1 || variable.references.some(ref => ref.isWrite() && !ref.init)) return true;
const def = variable.defs[0];
if (def.type !== "Variable" || def.parent.kind !== "const" || def.node.id.type !== AST_NODE_TYPES.Identifier) return true;
// Const fixes the binding, not the array's length. Reject prior uses
// that could mutate or pass the array to code that mutates it.
if (
variable.references.some(ref => {
const identifier = ref.identifier;
if (ref.init || identifier === node || identifier.range[0] >= node.range[0]) return false;
const parent = identifier.parent;
if (parent.type !== AST_NODE_TYPES.MemberExpression || parent.object !== identifier || parent.computed || parent.property.type !== AST_NODE_TYPES.Identifier || parent.property.name !== "length") return true;
const use = parent.parent;
return (use.type === AST_NODE_TYPES.AssignmentExpression && use.left === parent) || (use.type === AST_NODE_TYPES.UpdateExpression && use.argument === parent);
})
)
return true;
return !def.node.init || !isBoundedArrayExpression(def.node.init);
}
scope = scope.upper;
}
return true;
}
return node.type === AST_NODE_TYPES.MemberExpression || node.type === AST_NODE_TYPES.CallExpression;
}

return {
Expand Down
69 changes: 69 additions & 0 deletions eslint-factory/src/rules/no-unsafe-catch-error-property.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,75 @@ describe("no-unsafe-catch-error-property", () => {
});
});

it("valid: catch aliases guarded by instanceof or prior safe extraction are accepted", () => {
cjsRuleTester.run("no-unsafe-catch-error-property", noUnsafeCatchErrorPropertyRule, {
valid: [
`try { f(); } catch (error) { const err = error; if (err instanceof Error) { console.log(err.message); } }`,
`try { f(); } catch (error) { const err = error; if (!(err instanceof Error)) return; console.log(err.stack); }`,
`try { f(); } catch (error) { const err = error; if (error instanceof Error) { console.log(err.message); } }`,
`try { f(); } catch (error) { const err = error; getErrorMessage(err); console.log(err.message); }`,
`try { f(); } catch (error) { const err = new Error(); console.log(err.message); }`,
],
invalid: [],
});
});

it("invalid: unguarded const aliases of catch parameters are flagged by alias name", () => {
cjsRuleTester.run("no-unsafe-catch-error-property", noUnsafeCatchErrorPropertyRule, {
valid: [],
invalid: [
{
code: `try { f(); } catch (error) { const err = error; console.log(err.message); }`,
errors: [
{
messageId: "unsafeProperty",
data: { prop: "message", errorVar: "err" },
suggestions: [
{
messageId: "useGetErrorMessage",
data: { errorVar: "err" },
output: `try { f(); } catch (error) { const err = error; console.log(getErrorMessage(err)); }`,
},
],
},
],
},
{
code: `try { f(); } catch (err) { const e = /** @type {Error} */ err; console.log(e["stack"]); }`,
errors: [
{
messageId: "unsafeProperty",
data: { prop: "stack", errorVar: "e" },
suggestions: [
{
messageId: "wrapWithInstanceof",
data: { errorVar: "e", prop: "stack" },
output: `try { f(); } catch (err) { const e = /** @type {Error} */ err; console.log((e instanceof Error ? e.stack : undefined)); }`,
},
],
},
],
},
{
code: `try { f(); } catch (error) { const alias = error; error = new Error(); if (error instanceof Error) console.log(alias.message); }`,
errors: [
{
messageId: "unsafeProperty",
data: { prop: "message", errorVar: "alias" },
suggestions: [
{
messageId: "useGetErrorMessage",
data: { errorVar: "alias" },
output: `try { f(); } catch (error) { const alias = error; error = new Error(); if (error instanceof Error) console.log(getErrorMessage(alias)); }`,
},
],
},
],
},
],
});
});

it("valid: dynamic computed property access on catch variable is not flagged", () => {
cjsRuleTester.run("no-unsafe-catch-error-property", noUnsafeCatchErrorPropertyRule, {
valid: [`try { f(); } catch (err) { const prop = "message"; console.log(err[prop]); }`],
Expand Down
57 changes: 48 additions & 9 deletions eslint-factory/src/rules/no-unsafe-catch-error-property.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ const UNSAFE_PROPERTIES = new Set(["message", "stack", "code", "status", "cause"

interface CatchFrame {
varName: string;
param?: TSESTree.Identifier;
safeCalls: TSESTree.CallExpression[];
unsafeNodes: Array<{ node: TSESTree.MemberExpression; prop: string }>;
unsafeNodes: Array<{ node: TSESTree.MemberExpression; prop: string; varName: string }>;
}

function isInstanceofExprCheck(node: TSESTree.Expression, varName: string): boolean {
Expand Down Expand Up @@ -193,6 +194,40 @@ export const noUnsafeCatchErrorPropertyRule = createRule({
const sourceCode = context.sourceCode;
const catchStack: CatchFrame[] = [];

function resolveVariable(identifier: TSESTree.Identifier): TSESLint.Scope.Variable | undefined {
let scope: TSESLint.Scope.Scope | null = sourceCode.getScope(identifier);
while (scope) {
const variable = scope.set.get(identifier.name);
if (variable) return variable;
scope = scope.upper;
}
return undefined;
}

function caughtName(identifier: TSESTree.Identifier, frame: CatchFrame): string | null {
if (!frame.param) return null;
const caught = resolveVariable(frame.param);
const variable = resolveVariable(identifier);
if (!caught || !variable) return null;
if (variable === caught) return identifier.name;
if (variable.defs.length !== 1 || variable.references.some(ref => ref.isWrite() && !ref.init)) return null;
const def = variable.defs[0];
if (def.type !== "Variable" || def.parent.kind !== "const" || def.node.id.type !== AST_NODE_TYPES.Identifier) return null;
const init = def.node.init;
return init?.type === AST_NODE_TYPES.Identifier && resolveVariable(init) === caught ? identifier.name : null;
}

function catchParamUnchangedSinceAlias(frame: CatchFrame, memberExpr: TSESTree.MemberExpression): boolean {
if (!frame.param) return false;
const caught = resolveVariable(frame.param);
const obj = memberExpr.object;
if (!caught || obj.type !== AST_NODE_TYPES.Identifier) return false;
const alias = resolveVariable(obj);
const declarator = alias?.defs[0]?.node;
if (!declarator || declarator.type !== AST_NODE_TYPES.VariableDeclarator) return false;
return !caught.references.some(ref => ref.isWrite() && ref.identifier.range[0] >= declarator.range[1] && ref.identifier.range[0] < memberExpr.range[0]);
}

return {
CatchClause(node) {
const param = node.param;
Expand All @@ -204,19 +239,23 @@ export const noUnsafeCatchErrorPropertyRule = createRule({
return;
}

catchStack.push({ varName: param.name, safeCalls: [], unsafeNodes: [] });
catchStack.push({ varName: param.name, param, safeCalls: [], unsafeNodes: [] });
},

"CatchClause:exit"() {
const frame = catchStack.pop();
if (!frame || !frame.varName) return;

for (const { node: memberExpr, prop } of frame.unsafeNodes) {
if (isGuardedByAncestorBranch(sourceCode, memberExpr, frame.varName) || isCallOrderingGuarded(sourceCode, memberExpr, frame.safeCalls) || hasPriorEarlyExitInstanceofGuard(sourceCode, memberExpr, frame.varName)) {
for (const { node: memberExpr, prop, varName } of frame.unsafeNodes) {
if (
isGuardedByAncestorBranch(sourceCode, memberExpr, varName) ||
hasPriorEarlyExitInstanceofGuard(sourceCode, memberExpr, varName) ||
(varName !== frame.varName && catchParamUnchangedSinceAlias(frame, memberExpr) && (isGuardedByAncestorBranch(sourceCode, memberExpr, frame.varName) || hasPriorEarlyExitInstanceofGuard(sourceCode, memberExpr, frame.varName))) ||
isCallOrderingGuarded(sourceCode, memberExpr, frame.safeCalls)
Comment on lines +253 to +254
) {
continue;
}

const { varName } = frame;
const parent = memberExpr.parent;
const isChained =
parent != null &&
Expand Down Expand Up @@ -258,7 +297,7 @@ export const noUnsafeCatchErrorPropertyRule = createRule({
if (!top || !top.varName) return;

const firstArg = node.arguments[0];
if (node.callee.type === AST_NODE_TYPES.Identifier && node.callee.name === "getErrorMessage" && node.arguments.length >= 1 && firstArg.type === AST_NODE_TYPES.Identifier && firstArg.name === top.varName) {
if (node.callee.type === AST_NODE_TYPES.Identifier && node.callee.name === "getErrorMessage" && firstArg?.type === AST_NODE_TYPES.Identifier && caughtName(firstArg, top)) {
top.safeCalls.push(node);
}
},
Expand All @@ -273,18 +312,18 @@ export const noUnsafeCatchErrorPropertyRule = createRule({
const obj = node.object;
const prop = node.property;

if (obj.type !== AST_NODE_TYPES.Identifier || obj.name !== top.varName) return;
if (obj.type !== AST_NODE_TYPES.Identifier || !caughtName(obj, top)) return;

// Non-computed dot access: err.message / err.stack / err.code / err.status / err.cause / err.name
if (!node.computed && prop.type === AST_NODE_TYPES.Identifier && UNSAFE_PROPERTIES.has(prop.name)) {
top.unsafeNodes.push({ node, prop: prop.name });
top.unsafeNodes.push({ node, prop: prop.name, varName: obj.name });
return;
}

// Computed string-literal access: err["message"] / err["stack"] / err["status"] / etc.
// Dynamic access (err[prop]) is kept out of scope intentionally.
if (node.computed && prop.type === AST_NODE_TYPES.Literal && typeof prop.value === "string" && UNSAFE_PROPERTIES.has(prop.value)) {
top.unsafeNodes.push({ node, prop: prop.value });
top.unsafeNodes.push({ node, prop: prop.value, varName: obj.name });
}
},
};
Expand Down
Loading
Loading