Skip to content

eslint-factory: resolve promisify()-wrapped child_process bindings in prefer-actions-exec-over-child-process - #55687

Merged
pelikhan merged 2 commits into
mainfrom
copilot/eslint-factory-fix-promisify-issue
Aug 25, 2026
Merged

pelikhan merged 2 commits into
mainfrom
copilot/eslint-factory-fix-promisify-issue

Conversation

Copilot AI commented Aug 25, 2026 •

Copy link
Copy Markdown
Contributor

prefer-actions-exec-over-child-process resolved child_process methods only through direct destructuring, imports, and member access, so a binding created by promisify() was never recognized and the call silently passed — despite being the exact output-capturing anti-pattern the rule exists to catch (actions/setup/js/validate_secrets.cjs:252).

/// <reference types="@actions/github-script" />
const { promisify } = require("util");
const { exec } = require("child_process");
const execAsync = promisify(exec);

// previously unflagged, now reported
const { stdout, stderr } = await execAsync('which copilot 2>/dev/null || echo ""');

Changes

  • Binding resolution — resolveChildProcessOutputMethodBinding() now traces const x = promisify(<method ref>), handling both an identifier argument (recursively resolved, e.g. destructured/imported exec) and a member-expression argument (promisify(require("child_process").exec), util.promisify(cp.execFile)). A visited set guards against self-referential declarations.
  • Handle-retention exemption — the resolver returns { method, promisified }; the exemption for retained ChildProcess handles is skipped when promisified is true, since promisify(exec) resolves to captured output rather than a handle. Without this, const { stdout } = await execAsync(...) would still be exempted as a "retained handle".
  • Shared helper — member-expression resolution extracted into resolveChildProcessMemberMethod(), reused by the call-site path and both binding paths (no behavior change to existing forms).
  • promisify detection — matches a bare promisify(...) call or any <obj>.promisify(...) member call with exactly one argument. Scope is unchanged otherwise: spawn/spawnSync, non-child_process sources, and files without the @actions/github-script marker remain unflagged.
  • Tests / docs — new CJS + ESM cases for both binding shapes plus negatives (promisified spawn, exec from an unrelated module, fs.readFile, no marker, self-reference); rule description and eslint-factory/README.md updated.

validate_secrets.cjs is intentionally left unchanged — the issue asks for the call to be flagged, not fixed. It currently surfaces as one new warning.

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Copilot AI changed the title [WIP] Fix eslint-factory to handle promisify-wrapped child_process methods eslint-factory: resolve promisify()-wrapped child_process bindings in prefer-actions-exec-over-child-process Aug 25, 2026
Copilot AI requested a review from pelikhan August 25, 2026 06:32
@pelikhan
pelikhan marked this pull request as ready for review August 25, 2026 06:33
Copilot AI balanced review requested due to automatic review settings August 25, 2026 06:33
@github-actions

github-actions Bot commented Aug 25, 2026 •

Copy link
Copy Markdown
Contributor

✅ Test Quality Sentinel completed test quality analysis.

No test files were added or modified in this PR. Test Quality Sentinel skipped.

🧪 Test quality analysis by Test Quality Sentinel

@github-actions

github-actions Bot commented Aug 25, 2026 •

Copy link
Copy Markdown
Contributor

🧠 Matt Pocock Skills Reviewer has completed the skills-based review. ✅

🧠 Reviewed using Matt Pocock's skills by Matt Pocock Skills Reviewer

@github-actions

github-actions Bot commented Aug 25, 2026 •

Copy link
Copy Markdown
Contributor

✅ Ponytail Reviewer completed successfully!

Generated by Ponytail Reviewer for #55687

@github-actions

github-actions Bot commented Aug 25, 2026 •

Copy link
Copy Markdown
Contributor

✅ PR Code Quality Reviewer completed the code quality review.

🔎 Code quality review by PR Code Quality Reviewer

@github-actions

github-actions Bot commented Aug 25, 2026 •

Copy link
Copy Markdown
Contributor

✅ Design Decision Gate 🏗️ completed the design decision gate check.

No ADR enforcement needed: PR #55687 does not have the 'implementation' label and has 0 new lines of code in business logic directories (threshold: 100).

🏗️ ADR gate enforced by Design Decision Gate 🏗️

@pelikhan
pelikhan merged commit 40ccc7f into main Aug 25, 2026
23 checks passed
@pelikhan
pelikhan deleted the copilot/eslint-factory-fix-promisify-issue branch August 25, 2026 06:36
@github-actions

Copy link
Copy Markdown
Contributor

Comment Memory

reviewed_at: 2026-08-25T06:35:00Z
review_event: COMMENT
top_themes:
  - promisify resolution change looks internally consistent
  - tests cover the new binding shapes and recursion guard
  - no blocking correctness or performance regressions found
files_reviewed:
  - eslint-factory/README.md
  - eslint-factory/src/rules/prefer-actions-exec-over-child-process.test.ts
  - eslint-factory/src/rules/prefer-actions-exec-over-child-process.ts
comment_count: 0

Note

This comment is managed by comment memory.

It stores persistent context for this thread in the code block at the top of this comment.
Edit only the text inside the backtick fences; workflow metadata and the footer are regenerated automatically.

Learn more about comment memory

🔎 Code quality review by PR Code Quality Reviewer · pi · gpt54 · 4.7 AIC · ⌖ 7.89 AIC · ⊞ 7K · ◷
Comment /review to run again

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict

Non-blocking: the resolver change is narrowly scoped and the added tests cover the new promisify paths plus the main false-positive guards.

Highlights
  • I checked the new recursive binding resolution and the visited-set guard; it avoids the obvious self-reference loop and preserves the existing handle-retention exemption for non-promisified exec/execFile.
  • The test matrix covers CommonJS and ESM, direct/member-based promisify usage, and important negatives like spawn, unrelated exec, and files without the github-script marker.
  • I did not find a changed-line correctness, performance, or maintainability issue strong enough to block merge.

🔎 Code quality review by PR Code Quality Reviewer · pi · gpt54 · 4.7 AIC · ⌖ 7.89 AIC · ⊞ 7K
Comment /review to run again

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A couple of simplifications here would trim the patch without changing behavior. net: -20 lines possible.

Generated by ✂️ Ponytail Reviewer for #55687 · codex · mai10 · 7.5 AIC · ⌖ 1.92 AIC · ⊞ 16.7K
Comment /ponytail to run again

* Resolves whether `identifierName` is bound (directly or via destructuring/require/`promisify()`)
* to one of `OUTPUT_CAPTURING_METHODS` from the `child_process` module.
*/
function resolveChildProcessOutputMethodBinding(identifierName: string, scopeNode: TSESTree.Node, sourceCode: TSESLint.SourceCode, visited: Set<string> = new Set()): ResolvedChildProcessMethod | null {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

eslint-factory/src/rules/prefer-actions-exec-over-child-process.ts:108: yagni: recursive binding resolution plus a ResolvedChildProcessMethod wrapper for one promisify() branch. A small direct lookup for promisify(exec) would keep the rule simpler.

});
});

it("flags promisify()-wrapped child_process bindings", () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

eslint-factory/src/rules/prefer-actions-exec-over-child-process.test.ts:142: shrink: the new promisify() test block is a 10-case matrix for one resolver branch. Two focused CJS/ESM examples would cover the behavior without repeating almost identical fixtures.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Skills-Based Review 🧠

Applied /diagnosing-bugs and /tdd — two targeted issues worth addressing before merge.

📋 Key Themes & Highlights

Key Themes

  • False-positive risk: isPromisifyCall accepts any function named promisify regardless of source module, unlike the pattern used for child_process itself. This could flag non-util wrappers.
  • ESM test coverage gap: ESM invalid cases only test exec; execFile via promisify has no ESM coverage.

Positive Highlights

  • ✅ Clean ResolvedChildProcessMethod type narrowing — threading promisified: boolean through the resolution chain is elegant and avoids a second pass.
  • ✅ visited set for self-referential binding guard is exactly the right fix for the cycle risk.
  • ✅ Extracting resolveChildProcessMemberMethod() removes a meaningful duplication and the refactor is behaviour-preserving.
  • ✅ Good breadth of test cases (CJS + ESM, identifier + member-expression argument shapes, negatives for spawn, unrelated modules, fs).

🧠 Reviewed using Matt Pocock's skills by Matt Pocock Skills Reviewer · copilot · sonnet46 · 35.6 AIC · ⌖ 10.1 AIC · ⊞ 7.6K
Comment /matt to run again

if (!node || node.type !== AST_NODE_TYPES.CallExpression || node.arguments.length !== 1) return false;
const callee = node.callee;
if (callee.type === AST_NODE_TYPES.Identifier) return callee.name === "promisify";
return callee.type === AST_NODE_TYPES.MemberExpression && !callee.computed && callee.property.type === AST_NODE_TYPES.Identifier && callee.property.name === "promisify";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/diagnosing-bugs] isPromisifyCall matches any identifier named "promisify" or any *.promisify(...) call without verifying the source is util — a user-defined promisify wrapping a child_process method would be flagged incorrectly.

💡 Details and suggested test

Example that would be a false positive today:

// custom promisify, nothing to do with util
function promisify(fn) { return fn; }
const execAsync = promisify(exec);
execAsync("git status"); // ← incorrectly flagged

Either add a valid case covering a non-util promisify so the broad scope is intentional and documented, or narrow the check to also verify that the callee binding comes from "util" (similar to how isChildProcessModuleBinding tracks the child_process source). A short comment in isPromisifyCall would also clarify the deliberate choice.

@copilot please address this.

valid: [{ code: ghScript(`import { promisify } from "util"; import { spawn } from "child_process"; const spawnAsync = promisify(spawn); spawnAsync("node", ["server.js"]);`) }],
invalid: [
{
code: ghScript(`import { promisify } from "util"; import { exec } from "child_process"; const execAsync = promisify(exec); async function f() { await execAsync("git status"); }`),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] The ESM invalid cases cover only exec — there's no ESM test for execFile via util.promisify(cp.execFile) or the member-expression form import * as cp from "child_process"; util.promisify(cp.execFile). The CJS tests cover these shapes, but a parity gap in ESM leaves the import-binding path untested for execFile.

💡 Suggested addition
{
  code: ghScript(`import { promisify } from "util"; import { execFile } from "child_process"; const execFileAsync = promisify(execFile); async function f() { await execFileAsync("git", ["status"]); }`),
  errors: [{ messageId: "preferActionsExec", data: { method: "execFile" } }],
},

This ensures the ESM import-binding path through resolveChildProcessOutputMethodBinding is exercised for all flagged methods, not just exec.

@copilot please address this.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Extends the ESLint rule to detect promisified child_process output methods.

Changes:

  • Adds promisified binding resolution and cycle protection.
  • Updates CJS/ESM tests and documentation.
Show a summary per file
File Description
prefer-actions-exec-over-child-process.ts Adds promisified method resolution.
prefer-actions-exec-over-child-process.test.ts Adds positive and negative cases.
eslint-factory/README.md Documents the new behavior.

Review details

💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.

Suppressed comments (1)

eslint-factory/src/rules/prefer-actions-exec-over-child-process.ts:210

  • Promisified exec and execFile calls can still retain the process handle: Node attaches the ChildProcess as .child on the returned Promise. Consequently, const pending = execAsync("cmd"); pending.child.kill() is now reported even though it has the same lifecycle-management need as the existing exemption and @actions/exec cannot replace it. Preserve the exemption for uses that retain/access the promisified call's .child while still reporting awaited output captures.
        // A promisified binding resolves to captured output, never to a ChildProcess handle.
        if (!resolved.promisified && HANDLE_RETURNING_METHODS.has(resolved.method) && retainsCallResult(node)) return;
  • Files reviewed: 3/3 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment on lines +89 to +90
if (callee.type === AST_NODE_TYPES.Identifier) return callee.name === "promisify";
return callee.type === AST_NODE_TYPES.MemberExpression && !callee.computed && callee.property.type === AST_NODE_TYPES.Identifier && callee.property.name === "promisify";

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The implementation is correct and well-tested. The visited set prevents recursion, resolveChildProcessMemberMethod properly isolates member-resolution logic, and the promisified flag correctly suppresses the handle-retention exemption for promisified bindings.

Minor observation: isPromisifyCall identifies promisify calls by callee name alone without verifying the binding comes from util. This is safe in practice because the inner resolution still requires the wrapped argument to resolve to an actual child_process binding — so a false positive would require both a user-defined promisify function AND a child_process method in scope being passed to it.

Tests cover all key paths: self-referential guard, ESM imports, inline require(...).exec, util.promisify() member call form, and spawn exclusions. No blocking issues.

🧵 Reviewed using Impeccable skills by Impeccable Skills Reviewer · copilot · sonnet46 · 34.3 AIC · ⌖ 9.07 AIC · ⊞ 6.2K

@github-actions github-actions Bot mentioned this pull request Aug 25, 2026
@github-actions

Copy link
Copy Markdown
Contributor

🎉 This pull request is included in a new release.

Release: v0.87.8

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

eslint-factory: prefer-actions-exec-over-child-process misses promisify()-wrapped child_process methods

3 participants