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
2 changes: 1 addition & 1 deletion .github/workflows/agentic-auto-upgrade.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ name: Agentic Auto-Upgrade

on:
schedule:
- cron: "11 4 * * 6" # Weekly (auto-upgrade)
- cron: "21 3 * * 5" # Weekly (auto-upgrade)
workflow_dispatch:

permissions:
Expand Down
59 changes: 59 additions & 0 deletions eslint-factory/src/rules/core-method-resolve.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { AST_NODE_TYPES, TSESLint, TSESTree } from "@typescript-eslint/utils";
import { CORE_ALIASES } from "./core-aliases";

/**
* Checks whether an Identifier is a single-assignment alias for a core-like
* object (e.g., `const c = core`). Re-assigned let bindings are rejected.
* Local shadows (e.g., a parameter also named `c`) are excluded because they
* are found first in the scope chain and their definition type will not match.
*/
export function isCoreAliasIdentifier(identifier: TSESTree.Identifier, sourceCode: TSESLint.SourceCode): boolean {
let currentScope: TSESLint.Scope.Scope | null = sourceCode.getScope(identifier);
while (currentScope !== null) {
const variable = currentScope.set.get(identifier.name);
if (variable !== undefined) {
if (variable.defs.length !== 1) return false;
const def = variable.defs[0];
if (def.type !== "Variable") return false;
if (variable.references.some(ref => ref.isWrite() && !ref.init)) return false;
const declarator = def.node as TSESTree.VariableDeclarator;
if (!declarator.init) return false;
return declarator.id.type === AST_NODE_TYPES.Identifier && declarator.init.type === AST_NODE_TYPES.Identifier && CORE_ALIASES.has(declarator.init.name);
}
currentScope = currentScope.upper;
}
return false;
}

/**
* Checks whether an Identifier is a destructured binding for a specific
* @actions/core method from a core-like object (e.g., `const { setOutput } = core`
* or `const { setOutput: alias } = core` where `alias` is the identifier).
* Re-assigned let bindings are rejected. Local `function setOutput()` or
* parameter shadows are excluded via the `def.type !== "Variable"` guard.
*/
export function isDestructuredCoreMethodIdentifier(identifier: TSESTree.Identifier, methodName: string, sourceCode: TSESLint.SourceCode): boolean {
let currentScope: TSESLint.Scope.Scope | null = sourceCode.getScope(identifier);
while (currentScope !== null) {
const variable = currentScope.set.get(identifier.name);
if (variable !== undefined) {
if (variable.defs.length !== 1) return false;
const def = variable.defs[0];
if (def.type !== "Variable") return false;
if (variable.references.some(ref => ref.isWrite() && !ref.init)) return false;
const declarator = def.node as TSESTree.VariableDeclarator;
if (!declarator.init) return false;
if (declarator.id.type === AST_NODE_TYPES.ObjectPattern && declarator.init.type === AST_NODE_TYPES.Identifier && CORE_ALIASES.has(declarator.init.name)) {
return declarator.id.properties.some(prop => {
if (prop.type !== AST_NODE_TYPES.Property || prop.computed) return false;
const keyIsMethod = prop.key.type === AST_NODE_TYPES.Identifier && prop.key.name === methodName;
const valueIsAlias = prop.value.type === AST_NODE_TYPES.Identifier && prop.value.name === identifier.name;
return keyIsMethod && valueIsAlias;
});
}
return false;
}
currentScope = currentScope.upper;
}
return false;
}
83 changes: 83 additions & 0 deletions eslint-factory/src/rules/no-core-exportvariable-non-string.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,4 +229,87 @@ describe("no-core-exportvariable-non-string", () => {
],
});
});

it("valid: single-assignment const alias with string value is accepted", () => {
cjsRuleTester.run("no-core-exportvariable-non-string", noCoreExportVariableNonStringRule, {
valid: [`const c = core; c.exportVariable("MY_VAR", "hello");`, `const c = core; c.exportVariable("MY_VAR", someVariable);`, `const c = coreObj; c.exportVariable("MY_VAR", "hello");`],
invalid: [],
});
});

it("invalid: single-assignment const alias with non-string value is flagged", () => {
cjsRuleTester.run("no-core-exportvariable-non-string", noCoreExportVariableNonStringRule, {
valid: [],
invalid: [
{
code: `const c = core; c.exportVariable("MY_COUNT", items.length);`,
errors: [{ messageId: "nonStringValue", suggestions: [{ messageId: "wrapWithString", output: `const c = core; c.exportVariable("MY_COUNT", String(items.length));` }] }],
},
{
code: `const c = core; c.exportVariable("READY", true);`,
errors: [{ messageId: "nonStringValue", suggestions: [{ messageId: "wrapWithString", output: `const c = core; c.exportVariable("READY", String(true));` }] }],
},
{
code: `const c = core; c.exportVariable("MY_VAR", null);`,
errors: [
{
messageId: "nonStringValue",
suggestions: [
{ messageId: "useEmptyString", output: `const c = core; c.exportVariable("MY_VAR", "");` },
{ messageId: "wrapWithString", output: `const c = core; c.exportVariable("MY_VAR", String(null));` },
],
},
],
},
],
});
});

it("valid: let alias with reassignment is NOT flagged (not a safe const alias)", () => {
cjsRuleTester.run("no-core-exportvariable-non-string", noCoreExportVariableNonStringRule, {
valid: [`let c = core; c = other; c.exportVariable("MY_VAR", 1);`],
invalid: [],
});
});

it("valid: non-core const alias is NOT flagged", () => {
cjsRuleTester.run("no-core-exportvariable-non-string", noCoreExportVariableNonStringRule, {
valid: [`const c = other; c.exportVariable("MY_VAR", 1);`],
invalid: [],
});
});

it("valid: destructured exportVariable from core with string value is accepted", () => {
cjsRuleTester.run("no-core-exportvariable-non-string", noCoreExportVariableNonStringRule, {
valid: [`const { exportVariable } = core; exportVariable("MY_VAR", "hello");`, `const { exportVariable } = core; exportVariable("MY_VAR", someVariable);`, `const { exportVariable: ev } = core; ev("MY_VAR", "hello");`],
invalid: [],
});
});

it("invalid: destructured exportVariable from core with non-string value is flagged", () => {
cjsRuleTester.run("no-core-exportvariable-non-string", noCoreExportVariableNonStringRule, {
valid: [],
invalid: [
{
code: `const { exportVariable } = core; exportVariable("MY_COUNT", items.length);`,
errors: [{ messageId: "nonStringValue", suggestions: [{ messageId: "wrapWithString", output: `const { exportVariable } = core; exportVariable("MY_COUNT", String(items.length));` }] }],
},
{
code: `const { exportVariable } = core; exportVariable("READY", true);`,
errors: [{ messageId: "nonStringValue", suggestions: [{ messageId: "wrapWithString", output: `const { exportVariable } = core; exportVariable("READY", String(true));` }] }],
},
{
code: `const { exportVariable: ev } = core; ev("MY_COUNT", items.length);`,
errors: [{ messageId: "nonStringValue", suggestions: [{ messageId: "wrapWithString", output: `const { exportVariable: ev } = core; ev("MY_COUNT", String(items.length));` }] }],
},
],
});
});

it("valid: standalone exportVariable identifier from non-core source is NOT flagged", () => {
cjsRuleTester.run("no-core-exportvariable-non-string", noCoreExportVariableNonStringRule, {
valid: [`function exportVariable(k, v) {} exportVariable("MY_VAR", 1);`, `const { exportVariable } = other; exportVariable("MY_VAR", 1);`],
invalid: [],
});
});
});
26 changes: 16 additions & 10 deletions eslint-factory/src/rules/no-core-exportvariable-non-string.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { AST_NODE_TYPES, ESLintUtils, TSESLint } from "@typescript-eslint/utils";
import { CORE_ALIASES } from "./core-aliases";
import { isCoreAliasIdentifier, isDestructuredCoreMethodIdentifier } from "./core-method-resolve";
import { nonStringKind, NULL_KIND, UNDEFINED_KIND } from "./non-string-kind";

const createRule = ESLintUtils.RuleCreator(name => `https://github.com/github/gh-aw/tree/main/eslint-factory#${name}`);
Expand All @@ -11,7 +12,7 @@ export const noCoreExportVariableNonStringRule = createRule({
hasSuggestions: true,
docs: {
description:
"Require core.exportVariable value arguments to be explicit strings; passing numbers, booleans, null, undefined, or .length can silently produce unexpected string representations (e.g. 'null', 'true') in downstream GitHub Actions steps that read the exported environment variable. Detects only calls in the form core.exportVariable(name, value).",
"Require core.exportVariable value arguments to be explicit strings; passing numbers, booleans, null, undefined, or .length can silently produce unexpected string representations (e.g. 'null', 'true') in downstream GitHub Actions steps that read the exported environment variable. Detects calls in the form core.exportVariable(name, value), aliased (const c = core; c.exportVariable(...)), and destructured (const { exportVariable } = core; exportVariable(...)).",
},
schema: [],
messages: {
Expand All @@ -29,16 +30,21 @@ export const noCoreExportVariableNonStringRule = createRule({
CallExpression(node) {
const callee = node.callee;

// Must be a member expression: something.exportVariable(...)
if (callee.type !== AST_NODE_TYPES.MemberExpression) return;
if (callee.type === AST_NODE_TYPES.MemberExpression) {
// Object must be a known @actions/core alias or a single-assignment alias (e.g. `const c = core`)
if (callee.object.type !== AST_NODE_TYPES.Identifier) return;
if (!CORE_ALIASES.has(callee.object.name) && !isCoreAliasIdentifier(callee.object, sourceCode)) return;

// Object must be a known @actions/core alias (`core` or `coreObj`)
if (callee.object.type !== AST_NODE_TYPES.Identifier || !CORE_ALIASES.has(callee.object.name)) return;

// Property must be `exportVariable` (direct or computed string-literal access)
const prop = callee.property;
const isExportVariableProp = (!callee.computed && prop.type === AST_NODE_TYPES.Identifier && prop.name === "exportVariable") || (callee.computed && prop.type === AST_NODE_TYPES.Literal && prop.value === "exportVariable");
if (!isExportVariableProp) return;
// Property must be `exportVariable` (direct or computed string-literal access)
const prop = callee.property;
const isExportVariableProp = (!callee.computed && prop.type === AST_NODE_TYPES.Identifier && prop.name === "exportVariable") || (callee.computed && prop.type === AST_NODE_TYPES.Literal && prop.value === "exportVariable");
if (!isExportVariableProp) return;
} else if (callee.type === AST_NODE_TYPES.Identifier) {
// Destructured: `const { exportVariable } = core; exportVariable(...)` or `const { exportVariable: alias } = core; alias(...)`
if (!isDestructuredCoreMethodIdentifier(callee, "exportVariable", sourceCode)) return;
} else {
return;
}

// core.exportVariable expects exactly two arguments: (name, value)
if (node.arguments.length !== 2) return;
Expand Down
83 changes: 83 additions & 0 deletions eslint-factory/src/rules/no-core-setoutput-non-string.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,4 +247,87 @@ describe("no-core-setoutput-non-string", () => {
],
});
});

it("valid: single-assignment const alias with string value is accepted", () => {
cjsRuleTester.run("no-core-setoutput-non-string", noCoreSetOutputNonStringRule, {
valid: [`const c = core; c.setOutput("n", "str");`, `const c = core; c.setOutput("n", someVariable);`, `const c = coreObj; c.setOutput("n", "str");`],
invalid: [],
});
});

it("invalid: single-assignment const alias with non-string value is flagged", () => {
cjsRuleTester.run("no-core-setoutput-non-string", noCoreSetOutputNonStringRule, {
valid: [],
invalid: [
{
code: `const c = core; c.setOutput("count", items.length);`,
errors: [{ messageId: "nonStringValue", suggestions: [{ messageId: "wrapWithString", output: `const c = core; c.setOutput("count", String(items.length));` }] }],
},
{
code: `const c = core; c.setOutput("flag", true);`,
errors: [{ messageId: "nonStringValue", suggestions: [{ messageId: "wrapWithString", output: `const c = core; c.setOutput("flag", String(true));` }] }],
},
{
code: `const c = core; c.setOutput("result", null);`,
errors: [
{
messageId: "nonStringValue",
suggestions: [
{ messageId: "useEmptyString", output: `const c = core; c.setOutput("result", "");` },
{ messageId: "wrapWithString", output: `const c = core; c.setOutput("result", String(null));` },
],
},
],
},
],
});
});

it("valid: let alias with reassignment is NOT flagged (not a safe const alias)", () => {
cjsRuleTester.run("no-core-setoutput-non-string", noCoreSetOutputNonStringRule, {
valid: [`let c = core; c = other; c.setOutput("n", 1);`],
invalid: [],
});
});

it("valid: non-core const alias is NOT flagged", () => {
cjsRuleTester.run("no-core-setoutput-non-string", noCoreSetOutputNonStringRule, {
valid: [`const c = other; c.setOutput("n", 1);`],
invalid: [],
});
});

it("valid: destructured setOutput from core with string value is accepted", () => {
cjsRuleTester.run("no-core-setoutput-non-string", noCoreSetOutputNonStringRule, {
valid: [`const { setOutput } = core; setOutput("n", "str");`, `const { setOutput } = core; setOutput("n", someVariable);`, `const { setOutput: so } = core; so("n", "str");`],
invalid: [],
});
});

it("invalid: destructured setOutput from core with non-string value is flagged", () => {
cjsRuleTester.run("no-core-setoutput-non-string", noCoreSetOutputNonStringRule, {
valid: [],
invalid: [
{
code: `const { setOutput } = core; setOutput("count", items.length);`,
errors: [{ messageId: "nonStringValue", suggestions: [{ messageId: "wrapWithString", output: `const { setOutput } = core; setOutput("count", String(items.length));` }] }],
},
{
code: `const { setOutput } = core; setOutput("flag", true);`,
errors: [{ messageId: "nonStringValue", suggestions: [{ messageId: "wrapWithString", output: `const { setOutput } = core; setOutput("flag", String(true));` }] }],
},
{
code: `const { setOutput: so } = core; so("n", items.length);`,
errors: [{ messageId: "nonStringValue", suggestions: [{ messageId: "wrapWithString", output: `const { setOutput: so } = core; so("n", String(items.length));` }] }],
},
],
});
});

it("valid: standalone setOutput identifier from non-core source is NOT flagged", () => {
cjsRuleTester.run("no-core-setoutput-non-string", noCoreSetOutputNonStringRule, {
valid: [`function setOutput(k, v) {} setOutput("n", 1);`, `const { setOutput } = other; setOutput("n", 1);`],
invalid: [],
});
});
});
26 changes: 16 additions & 10 deletions eslint-factory/src/rules/no-core-setoutput-non-string.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { AST_NODE_TYPES, ESLintUtils, TSESLint } from "@typescript-eslint/utils";
import { CORE_ALIASES } from "./core-aliases";
import { isCoreAliasIdentifier, isDestructuredCoreMethodIdentifier } from "./core-method-resolve";
import { nonStringKind, NULL_KIND, UNDEFINED_KIND } from "./non-string-kind";

const createRule = ESLintUtils.RuleCreator(name => `https://github.com/github/gh-aw/tree/main/eslint-factory#${name}`);
Expand All @@ -11,7 +12,7 @@ export const noCoreSetOutputNonStringRule = createRule({
hasSuggestions: true,
docs: {
description:
"Require core.setOutput value arguments to be explicit strings; passing numbers, booleans, null, undefined, or .length can silently produce unexpected string representations (e.g. 'null', 'true') in downstream GitHub Actions workflow expressions. Detects only calls in the form core.setOutput(name, value).",
"Require core.setOutput value arguments to be explicit strings; passing numbers, booleans, null, undefined, or .length can silently produce unexpected string representations (e.g. 'null', 'true') in downstream GitHub Actions workflow expressions. Detects calls in the form core.setOutput(name, value), aliased (const c = core; c.setOutput(...)), and destructured (const { setOutput } = core; setOutput(...)).",
},
schema: [],
messages: {
Expand All @@ -29,16 +30,21 @@ export const noCoreSetOutputNonStringRule = createRule({
CallExpression(node) {
const callee = node.callee;

// Must be a member expression: something.setOutput(...)
if (callee.type !== AST_NODE_TYPES.MemberExpression) return;
if (callee.type === AST_NODE_TYPES.MemberExpression) {
// Object must be a known @actions/core alias or a single-assignment alias (e.g. `const c = core`)
if (callee.object.type !== AST_NODE_TYPES.Identifier) return;
if (!CORE_ALIASES.has(callee.object.name) && !isCoreAliasIdentifier(callee.object, sourceCode)) return;

// Object must be a known @actions/core alias (`core` or `coreObj`)
if (callee.object.type !== AST_NODE_TYPES.Identifier || !CORE_ALIASES.has(callee.object.name)) return;

// Property must be `setOutput` (direct or computed string-literal access)
const prop = callee.property;
const isSetOutputProp = (!callee.computed && prop.type === AST_NODE_TYPES.Identifier && prop.name === "setOutput") || (callee.computed && prop.type === AST_NODE_TYPES.Literal && prop.value === "setOutput");
if (!isSetOutputProp) return;
// Property must be `setOutput` (direct or computed string-literal access)
const prop = callee.property;
const isSetOutputProp = (!callee.computed && prop.type === AST_NODE_TYPES.Identifier && prop.name === "setOutput") || (callee.computed && prop.type === AST_NODE_TYPES.Literal && prop.value === "setOutput");
if (!isSetOutputProp) return;
} else if (callee.type === AST_NODE_TYPES.Identifier) {
// Destructured: `const { setOutput } = core; setOutput(...)` or `const { setOutput: alias } = core; alias(...)`
if (!isDestructuredCoreMethodIdentifier(callee, "setOutput", sourceCode)) return;
} else {
return;
}

// core.setOutput expects exactly two arguments: (name, value)
if (node.arguments.length !== 2) return;
Expand Down
Loading