[eslint-miner] feat(eslint): add no-core-exportvariable-non-string rule#45075
Conversation
Add a new ESLint rule that flags non-string values passed to core.exportVariable() — numbers, booleans, null, undefined, or .length — that silently produce unexpected strings (e.g. 'null', 'true') when the exported env var is read by downstream steps. This mirrors the existing no-core-setoutput-non-string rule and closes a preventive gap: all current exportVariable calls in actions/setup/js already use strings, but the rule guards against future regressions and matches the codebase convention. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
✅ PR Code Quality Reviewer completed the code quality review. |
|
✅ Design Decision Gate 🏗️ completed the design decision gate check. No ADR enforcement needed: PR #45075 does not have the 'implementation' label and has 0 new lines of code in business logic directories (threshold: 100). |
|
✅ Test Quality Sentinel completed test quality analysis. |
|
🧠 Matt Pocock Skills Reviewer has completed the skills-based review. ✅ |
There was a problem hiding this comment.
Pull request overview
Adds an ESLint rule requiring explicit string values for core.exportVariable() calls.
Changes:
- Implements and registers the new rule.
- Adds valid, invalid, and suggestion tests.
- Enables the rule as a warning for setup JavaScript.
Show a summary per file
| File | Description |
|---|---|
eslint-factory/src/rules/no-core-exportvariable-non-string.ts |
Implements detection and suggestions. |
eslint-factory/src/rules/no-core-exportvariable-non-string.test.ts |
Tests rule behavior. |
eslint-factory/src/index.ts |
Registers the rule. |
eslint-factory/eslint.config.cjs |
Enables the rule. |
Review details
Tip
Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Files reviewed: 4/4 changed files
- Comments generated: 5
- Review effort level: Medium
| schema: [], | ||
| messages: { | ||
| nonStringValue: | ||
| "The exportVariable value {{valueText}} is a {{kind}}. Implicit coercion may produce unexpected strings such as 'null' or 'true' when the environment variable is read by downstream steps. Use an explicit string conversion and choose the suggestion that matches the intended output semantics.", |
| messageId: "wrapWithString" as const, | ||
| data: { valueText }, | ||
| fix(fixer: TSESLint.RuleFixer) { | ||
| return fixer.replaceText(valueArg, `String(${valueText})`); | ||
| }, |
| if (node.type === AST_NODE_TYPES.Literal) { | ||
| if (typeof node.value === "number") return NUMERIC_LITERAL_KIND; | ||
| if (typeof node.value === "boolean") return BOOLEAN_LITERAL_KIND; | ||
| if (node.value === null) return NULL_KIND; |
| if (node.type === AST_NODE_TYPES.Identifier && node.name === "undefined") { | ||
| return UNDEFINED_KIND; |
| 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).", |
There was a problem hiding this comment.
Review: no-core-exportvariable-non-string
Clean, correct implementation mirroring the existing no-core-setoutput-non-string rule. Logic, tests (10/10), and registration all look good.
One non-blocking suggestion (see inline comment): nonStringKind and the five kind constants are duplicated verbatim from the sibling rule file. Extracting them to a shared rule-utils.ts would eliminate the duplication and prevent drift when new targeted forms are added in future.
🧵 Reviewed using Impeccable skills by Impeccable Skills Reviewer · 19.2 AIC · ⌖ 4.37 AIC · ⊞ 4.8K
| * - Identifier `undefined` | ||
| * - .length member access: commonly numeric in practice | ||
| */ | ||
| const NUMERIC_LITERAL_KIND = "numeric literal" as const; |
There was a problem hiding this comment.
The nonStringKind helper function and the five kind-constant declarations (NUMERIC_LITERAL_KIND, BOOLEAN_LITERAL_KIND, NULL_KIND, UNDEFINED_KIND, LENGTH_KIND) are duplicated verbatim between no-core-exportvariable-non-string.ts and no-core-setoutput-non-string.ts. Consider extracting them to a shared rule-utils.ts module so future changes (e.g., adding a new targeted form) only need to be made once.
There was a problem hiding this comment.
Solid, well-scoped addition. One non-blocking observation on the argument-count guard (commented inline).
### Review themes
No blocking issues found. The rule is a faithful mirror of the existing no-core-setoutput-non-string sibling with appropriate targeted patterns, clear messages, actionable suggestions, and thorough test coverage (valid + invalid + both suggestion variants for null/undefined).
One low-priority note (commented inline):
- The
arguments.length !== 2early-return silently skips spread-argument calls — not a real risk given the fixed API signature, but deserves a one-line comment for future maintainers.
Hardcoded core object name: consistent with the sibling rule's design; aliased imports will not be caught, but this is a documented limitation of syntax-only rules without type information and is not a regression.
🔎 Code quality review by PR Code Quality Reviewer · 26.3 AIC · ⌖ 4.43 AIC · ⊞ 5.4K
Comment /review to run again
| context.report({ | ||
| node, | ||
| messageId: "nonStringValue", | ||
| data: { kind, valueText }, |
There was a problem hiding this comment.
Silent skip on spread/rest arguments could hide violations: node.arguments.length !== 2 silently ignores core.exportVariable(...args) with no diagnostic.
💡 Suggested fix
Low practical risk since the API is always (name, val), but the intent should be documented:
// Spread-argument calls (e.g. core.exportVariable(...args)) are intentionally excluded —
// the rule targets only literal call sites where the value type is statically known.
if (node.arguments.length !== 2) return;This mirrors the same guard in the sibling no-core-setoutput-non-string rule, so consistency is preserved.
🧪 Test Quality Sentinel Report✅ Test Quality Score: 86/100 — Excellent
📊 Metrics (9 tests)
|
There was a problem hiding this comment.
Skills-Based Review 🧠
Applied /codebase-design and /tdd — requesting changes on two issues.
📋 Key Themes & Highlights
Issues
- Code duplication:
nonStringKindand all its constants are a verbatim copy fromno-core-setoutput-non-string.ts. A shared utility module removes a future maintenance hazard. - Arity guard too strict:
arguments.length !== 2silently missescore.exportVariable('X', 42, extra). Change to< 2and this edge case is also covered by a new test.
Positive Highlights
- ✅ Excellent test coverage for the targeted forms (numeric, boolean, null, undefined, .length)
- ✅ Two-suggestion design for null/undefined (empty string vs. String()) is thoughtful UX
- ✅ Clear and rationale-driven PR description with coercion examples
- ✅ Zero baseline warnings confirms the rule is not noisy
- ✅ Rule structure mirrors the existing pattern cleanly
Minor (non-blocking)
- The sibling rule
no-core-setoutput-non-stringis documented ineslint-factory/README.mdat line 83. This new rule should get a matching entry.
🧠 Reviewed using Matt Pocock's skills by Matt Pocock Skills Reviewer · 34.1 AIC · ⌖ 4.7 AIC · ⊞ 6.6K
Comment /matt to run again
|
|
||
| function nonStringKind(node: TSESTree.Node): NonStringKind | null { | ||
| if (node.type === AST_NODE_TYPES.Literal) { | ||
| if (typeof node.value === "number") return NUMERIC_LITERAL_KIND; |
There was a problem hiding this comment.
[/codebase-design] nonStringKind and its constants are copied verbatim from no-core-setoutput-non-string.ts — any future bug fix to detection logic must be applied in two places.
💡 Suggested fix
Extract the shared detection logic to a utility module:
// eslint-factory/src/rules/shared/non-string-kind.ts
export const NUMERIC_LITERAL_KIND = 'numeric literal' as const;
// ... other constants ...
export type NonStringKind = ...;
export function nonStringKind(node: TSESTree.Node): NonStringKind | null { ... }Both rules then import from that module. This follows the deep-module pattern: one seam for detection logic, two thin wrappers for rule-specific reporting.
@copilot please address this.
| if (callee.type !== AST_NODE_TYPES.MemberExpression) return; | ||
|
|
||
| // Object must be `core` (the @actions/core import convention in actions/setup/js) | ||
| if (callee.object.type !== AST_NODE_TYPES.Identifier || callee.object.name !== "core") return; |
There was a problem hiding this comment.
[/codebase-design] The arity guard node.arguments.length !== 2 silently skips a call like core.exportVariable('X', 42, extra) — a non-string value goes undetected if extra arguments are present.
💡 Suggested fix
Guard on minimum arity (< 2) and look at only the second argument regardless of extra arguments:
if (node.arguments.length < 2) return;This matches how the real @actions/core API works and makes the rule robust to minor call-site variations.
@copilot please address this.
| }, | ||
| ], | ||
| }, | ||
| ], |
There was a problem hiding this comment.
[/tdd] No test for the arity guard edge case: core.exportVariable('X', 42, extra) with three arguments. If the guard is ever loosened (or changed to < 2), there's no regression test to verify detection still works with extra args.
💡 Suggested test
it('invalid: numeric literal with extra arguments is still flagged', () => {
cjsRuleTester.run('no-core-exportvariable-non-string', noCoreExportVariableNonStringRule, {
valid: [],
invalid: [
{
code: `core.exportVariable("MY_COUNT", 42, extra);`,
errors: [{ messageId: 'nonStringValue' }],
},
],
});
});@copilot please address this.
|
🎉 This pull request is included in a new release. Release: |
Summary
Adds a new ESLint rule
no-core-exportvariable-non-stringto thegh-aw-customESLint plugin. The rule warns whencore.exportVariable(name, value)is called with a non-string value argument, preventing silent implicit coercions (e.g.,nullstringified as"null",trueas"true") that can cause unexpected behaviour in downstream GitHub Actions steps that consume the exported environment variable.Motivation
@actions/core'sexportVariablewrites a value to$GITHUB_ENV. When callers pass numeric literals, booleans,null,undefined, or.lengthexpressions, JavaScript silently coerces the value to a string. The coercion result may not match the caller's intent and can be misread by downstream steps. Requiring an explicitString(...)call or template literal makes the coercion visible and intentional.Changes
eslint-factory/src/rules/no-core-exportvariable-non-string.ts(new, 122 lines)@typescript-eslint/utils(ESLintUtils.RuleCreator).null,undefined, and.lengthmember accesses."problem"; auto-fix suggestions viahasSuggestions: true.null/undefined: two suggestions — replace with""or wrap withString(...).String(...).core.exportVariable(...)andcore["exportVariable"](...)call forms; ignores callee objects other than exactlycore.eslint-factory/src/rules/no-core-exportvariable-non-string.test.ts(new, 172 lines)RuleTestersuite.String(...),.toString(), arbitrary variable references, non-corereceiver objects.0,42), boolean literals (true,false),null,undefined,.lengthmember access, computedcore["exportVariable"]with a numeric argument.messageIdvalues and exact suggestion fix outputs for each invalid case.eslint-factory/src/index.tsnoCoreExportVariableNonStringRuleunder key"no-core-exportvariable-non-string"in the plugin rules map.eslint-factory/eslint.config.cjs"gh-aw-custom/no-core-exportvariable-non-string"at severity"warn"in the default plugin config.Design Decisions
.lengthvia bracket notation (expr["length"]) is excluded to further reduce false positives."warn"rather than"error"to allow gradual adoption across the codebase.