From 85109b876a32588082889e232b5ccb65869dcbcc Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 19 Jul 2026 09:31:36 +0000 Subject: [PATCH 1/2] eslint: add no-exec-interpolated-command rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new custom ESLint rule that flags calls to exec.exec() and exec.getExecOutput() where the first (command) argument is an interpolated template literal or a dynamic string concatenation. The @actions/exec runner splits the command string by spaces internally; variable values containing spaces silently break argument boundaries, causing subtle argument-splitting bugs. The safe pattern is to pass a static command string and move all dynamic values to the args array: // unsafe — if branchName contains spaces, arguments are split incorrectly await exec.exec(`git checkout -B ${branchName} ${ref}`); // safe — arguments are passed verbatim without shell splitting await exec.exec('git', ['checkout', '-B', branchName, ref]); Evidence: 10 existing violations found across actions/setup/js: - create_pull_request.cjs (7 violations) - push_to_pull_request_branch.cjs (3 violations) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- eslint-factory/eslint.config.cjs | 1 + eslint-factory/src/index.ts | 2 + .../no-exec-interpolated-command.test.ts | 55 ++++++++++ .../src/rules/no-exec-interpolated-command.ts | 101 ++++++++++++++++++ 4 files changed, 159 insertions(+) create mode 100644 eslint-factory/src/rules/no-exec-interpolated-command.test.ts create mode 100644 eslint-factory/src/rules/no-exec-interpolated-command.ts diff --git a/eslint-factory/eslint.config.cjs b/eslint-factory/eslint.config.cjs index 6d300ca7e82..b61b4ff1b6f 100644 --- a/eslint-factory/eslint.config.cjs +++ b/eslint-factory/eslint.config.cjs @@ -33,6 +33,7 @@ module.exports = [ "gh-aw-custom/require-new-url-try-catch": "warn", "gh-aw-custom/prefer-core-logging": "warn", "gh-aw-custom/no-core-error-then-process-exit": "warn", + "gh-aw-custom/no-exec-interpolated-command": "warn", }, }, { diff --git a/eslint-factory/src/index.ts b/eslint-factory/src/index.ts index 9fee1a0ea63..8e574b0e61a 100644 --- a/eslint-factory/src/index.ts +++ b/eslint-factory/src/index.ts @@ -19,6 +19,7 @@ import { requireSpawnSyncErrorCheckRule } from "./rules/require-spawnsync-error- import { requireNewUrlTryCatchRule } from "./rules/require-new-url-try-catch"; import { preferCoreLoggingRule } from "./rules/prefer-core-logging"; import { noCoreErrorThenProcessExitRule } from "./rules/no-core-error-then-process-exit"; +import { noExecInterpolatedCommandRule } from "./rules/no-exec-interpolated-command"; const plugin = { meta: { @@ -47,6 +48,7 @@ const plugin = { "require-new-url-try-catch": requireNewUrlTryCatchRule, "prefer-core-logging": preferCoreLoggingRule, "no-core-error-then-process-exit": noCoreErrorThenProcessExitRule, + "no-exec-interpolated-command": noExecInterpolatedCommandRule, }, }; diff --git a/eslint-factory/src/rules/no-exec-interpolated-command.test.ts b/eslint-factory/src/rules/no-exec-interpolated-command.test.ts new file mode 100644 index 00000000000..8fedb43f5a5 --- /dev/null +++ b/eslint-factory/src/rules/no-exec-interpolated-command.test.ts @@ -0,0 +1,55 @@ +import { RuleTester } from "@typescript-eslint/rule-tester"; +import { noExecInterpolatedCommandRule } from "./no-exec-interpolated-command"; + +const ruleTester = new RuleTester({ + languageOptions: { + ecmaVersion: "latest", + sourceType: "commonjs", + }, +}); + +ruleTester.run("no-exec-interpolated-command", noExecInterpolatedCommandRule, { + valid: [ + // Static string — no interpolation, safe + { code: `exec.exec("git", ["checkout", branch]);` }, + // Static template literal — no expressions, safe + { code: "exec.exec(`git`, [`checkout`, branch]);" }, + // getExecOutput with static command + { code: `exec.getExecOutput("git", ["rev-parse", "--abbrev-ref", "HEAD"], opts);` }, + // Command variable (identifier) — not a string literal, out of scope + { code: `exec.exec(myCommand, [arg1]);` }, + // Single-word static template literal — no interpolation + { code: "exec.exec(`git`, [branch]);" }, + // Not exec.exec — unrelated call + { code: `someOther.exec(\`git checkout \${branch}\`);` }, + // Bare exec() call — not a member expression + { code: `exec(\`git checkout \${branch}\`);` }, + ], + invalid: [ + // Template literal with interpolation as command + { + code: "exec.exec(`git checkout ${branch}`, [], opts);", + errors: [{ messageId: "interpolatedCommand" }], + }, + // Template literal with multiple interpolations + { + code: "exec.exec(`git checkout -B ${branchName} ${baseRef}`, [], opts);", + errors: [{ messageId: "interpolatedCommand" }], + }, + // Dynamic string concatenation + { + code: `exec.exec("git checkout " + branchName, [], opts);`, + errors: [{ messageId: "interpolatedCommand" }], + }, + // getExecOutput with interpolated command + { + code: "exec.getExecOutput(`git rev-parse --verify ${ref}`, [], opts);", + errors: [{ messageId: "interpolatedCommand" }], + }, + // Template with only a single interpolation (whole command dynamic) + { + code: "exec.exec(`git am --3way ${patchPath}`, [], opts);", + errors: [{ messageId: "interpolatedCommand" }], + }, + ], +}); diff --git a/eslint-factory/src/rules/no-exec-interpolated-command.ts b/eslint-factory/src/rules/no-exec-interpolated-command.ts new file mode 100644 index 00000000000..9bac91ad9de --- /dev/null +++ b/eslint-factory/src/rules/no-exec-interpolated-command.ts @@ -0,0 +1,101 @@ +import { AST_NODE_TYPES, ESLintUtils, TSESTree } from "@typescript-eslint/utils"; + +const createRule = ESLintUtils.RuleCreator(name => `https://github.com/github/gh-aw/tree/main/eslint-factory#${name}`); + +/** + * Returns true when the node is a template literal that contains at least one + * interpolated expression. + */ +function isInterpolatedTemplateLiteral(node: TSESTree.Node): boolean { + return node.type === "TemplateLiteral" && node.expressions.length > 0; +} + +/** + * Returns true when the node is a purely static expression (no runtime + * interpolation): a string literal, a no-expression template literal, or a + * binary `+` of two static expressions. + */ +function isStaticExpression(node: TSESTree.Node): boolean { + if (node.type === "Literal") return typeof node.value === "string"; + if (node.type === "TemplateLiteral") return node.expressions.length === 0; + if (node.type === "BinaryExpression" && node.operator === "+") { + return isStaticExpression(node.left) && isStaticExpression(node.right); + } + return false; +} + +/** + * Returns true when the node is a dynamic string concatenation (binary `+` + * that is not entirely static). + */ +function isDynamicStringConcatenation(node: TSESTree.Node): boolean { + return node.type === "BinaryExpression" && node.operator === "+" && !isStaticExpression(node); +} + +/** + * Returns the display kind string for the problematic first argument, or null + * when the argument is not one of the flagged shapes. + */ +function getDynamicCommandKind(node: TSESTree.Node): string | null { + if (isInterpolatedTemplateLiteral(node)) return "interpolated template literal"; + if (isDynamicStringConcatenation(node)) return "dynamic string concatenation"; + return null; +} + +/** + * Returns true when the call expression looks like `exec.exec(...)` or + * `exec.getExecOutput(...)` — the `exec` global injected by github-script. + * + * Recognized shapes: + * exec.exec(cmd, args?, opts?) + * exec.getExecOutput(cmd, args?, opts?) + */ +function isExecCall(node: TSESTree.CallExpression): boolean { + const callee = node.callee; + if (callee.type !== AST_NODE_TYPES.MemberExpression) return false; + if (callee.computed) return false; + const obj = callee.object; + const prop = callee.property; + if (obj.type !== AST_NODE_TYPES.Identifier || obj.name !== "exec") return false; + if (prop.type !== AST_NODE_TYPES.Identifier) return false; + return prop.name === "exec" || prop.name === "getExecOutput"; +} + +export const noExecInterpolatedCommandRule = createRule({ + name: "no-exec-interpolated-command", + meta: { + type: "problem", + docs: { + description: + "Disallow interpolated template literals or dynamic string concatenation as the first (command) argument of exec.exec() or exec.getExecOutput(). " + + "The @actions/exec runner splits the command string by spaces internally; variables containing spaces silently break argument boundaries. " + + "Pass a static command string and put all arguments in the second array parameter instead: exec.exec('git', [arg1, arg2]).", + }, + schema: [], + messages: { + interpolatedCommand: + "Avoid passing a {{kind}} as the exec command — @actions/exec splits the command string by spaces, so values containing spaces silently break argument boundaries. " + + "Use a static command string and pass all arguments in the args array: exec.exec('git', ['checkout', branchName]).", + }, + }, + defaultOptions: [], + create(context) { + return { + CallExpression(node) { + if (!isExecCall(node)) return; + + const firstArg = node.arguments[0]; + if (!firstArg) return; + + const kind = getDynamicCommandKind(firstArg); + if (!kind) return; + + context.report({ + node: firstArg, + messageId: "interpolatedCommand", + data: { kind }, + }); + }, + }; + }, +}); From 60b8f0c1847b7d0b400a7895c5b63e30e9f7844c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 19 Jul 2026 10:16:36 +0000 Subject: [PATCH 2/2] Refine exec command lint rule Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../no-exec-interpolated-command.test.ts | 108 ++++++++++-------- .../src/rules/no-exec-interpolated-command.ts | 48 ++++---- pkg/linters/README.md | 5 + 3 files changed, 90 insertions(+), 71 deletions(-) diff --git a/eslint-factory/src/rules/no-exec-interpolated-command.test.ts b/eslint-factory/src/rules/no-exec-interpolated-command.test.ts index 8fedb43f5a5..8df34aa41fd 100644 --- a/eslint-factory/src/rules/no-exec-interpolated-command.test.ts +++ b/eslint-factory/src/rules/no-exec-interpolated-command.test.ts @@ -1,4 +1,5 @@ -import { RuleTester } from "@typescript-eslint/rule-tester"; +import { RuleTester } from "eslint"; +import { describe, it } from "vitest"; import { noExecInterpolatedCommandRule } from "./no-exec-interpolated-command"; const ruleTester = new RuleTester({ @@ -8,48 +9,65 @@ const ruleTester = new RuleTester({ }, }); -ruleTester.run("no-exec-interpolated-command", noExecInterpolatedCommandRule, { - valid: [ - // Static string — no interpolation, safe - { code: `exec.exec("git", ["checkout", branch]);` }, - // Static template literal — no expressions, safe - { code: "exec.exec(`git`, [`checkout`, branch]);" }, - // getExecOutput with static command - { code: `exec.getExecOutput("git", ["rev-parse", "--abbrev-ref", "HEAD"], opts);` }, - // Command variable (identifier) — not a string literal, out of scope - { code: `exec.exec(myCommand, [arg1]);` }, - // Single-word static template literal — no interpolation - { code: "exec.exec(`git`, [branch]);" }, - // Not exec.exec — unrelated call - { code: `someOther.exec(\`git checkout \${branch}\`);` }, - // Bare exec() call — not a member expression - { code: `exec(\`git checkout \${branch}\`);` }, - ], - invalid: [ - // Template literal with interpolation as command - { - code: "exec.exec(`git checkout ${branch}`, [], opts);", - errors: [{ messageId: "interpolatedCommand" }], - }, - // Template literal with multiple interpolations - { - code: "exec.exec(`git checkout -B ${branchName} ${baseRef}`, [], opts);", - errors: [{ messageId: "interpolatedCommand" }], - }, - // Dynamic string concatenation - { - code: `exec.exec("git checkout " + branchName, [], opts);`, - errors: [{ messageId: "interpolatedCommand" }], - }, - // getExecOutput with interpolated command - { - code: "exec.getExecOutput(`git rev-parse --verify ${ref}`, [], opts);", - errors: [{ messageId: "interpolatedCommand" }], - }, - // Template with only a single interpolation (whole command dynamic) - { - code: "exec.exec(`git am --3way ${patchPath}`, [], opts);", - errors: [{ messageId: "interpolatedCommand" }], - }, - ], +describe("no-exec-interpolated-command", () => { + it("accepts static command forms and flags dynamic ones", () => { + ruleTester.run("no-exec-interpolated-command", noExecInterpolatedCommandRule, { + valid: [ + // Static string — no interpolation, safe + { code: `exec.exec("git", ["checkout", branch]);` }, + // Static template literal — no expressions, safe + { code: "exec.exec(`git`, [`checkout`, branch]);" }, + // getExecOutput with static command + { code: `exec.getExecOutput("git", ["rev-parse", "--abbrev-ref", "HEAD"], opts);` }, + // Command variable (identifier) — not a string literal, out of scope + { code: `exec.exec(myCommand, [arg1]);` }, + // Single-word static template literal — no interpolation + { code: "exec.exec(`git`, [branch]);" }, + // Fully static concatenation built from literal leaves + { code: `exec.exec("tool --retries " + 3, [], opts);` }, + // Fully static string concatenation remains allowed + { code: `exec.exec("git" + " checkout", [branch]);` }, + // Not exec.exec — unrelated call + { code: `someOther.exec(\`git checkout \${branch}\`);` }, + // Alias object name is intentionally out of scope + { code: `execAlias.exec(\`git checkout \${branch}\`);` }, + // Bare exec() call — not a member expression + { code: `exec(\`git checkout \${branch}\`);` }, + // Spread first argument is intentionally out of scope + { code: `exec.exec(...args);` }, + ], + invalid: [ + // Template literal with interpolation as command + { + code: "exec.exec(`git checkout ${branch}`, [], opts);", + errors: [{ messageId: "interpolatedCommand" }], + }, + // Template literal with multiple interpolations + { + code: "exec.exec(`git checkout -B ${branchName} ${baseRef}`, [], opts);", + errors: [{ messageId: "interpolatedCommand" }], + }, + // Dynamic string concatenation + { + code: `exec.exec("git checkout " + branchName, [], opts);`, + errors: [{ messageId: "interpolatedCommand", data: { kind: "dynamic string concatenation", method: "exec" } }], + }, + // Multi-segment dynamic string concatenation + { + code: `exec.exec("git checkout " + branchName + " " + ref, [], opts);`, + errors: [{ messageId: "interpolatedCommand", data: { kind: "dynamic string concatenation", method: "exec" } }], + }, + // getExecOutput with interpolated command + { + code: "exec.getExecOutput(`git rev-parse --verify ${ref}`, [], opts);", + errors: [{ messageId: "interpolatedCommand", data: { kind: "interpolated template literal", method: "getExecOutput" } }], + }, + // Template with only a single interpolation (whole command dynamic) + { + code: "exec.exec(`git am --3way ${patchPath}`, [], opts);", + errors: [{ messageId: "interpolatedCommand", data: { kind: "interpolated template literal", method: "exec" } }], + }, + ], + }); + }); }); diff --git a/eslint-factory/src/rules/no-exec-interpolated-command.ts b/eslint-factory/src/rules/no-exec-interpolated-command.ts index 9bac91ad9de..42fd04d4587 100644 --- a/eslint-factory/src/rules/no-exec-interpolated-command.ts +++ b/eslint-factory/src/rules/no-exec-interpolated-command.ts @@ -1,22 +1,15 @@ import { AST_NODE_TYPES, ESLintUtils, TSESTree } from "@typescript-eslint/utils"; const createRule = ESLintUtils.RuleCreator(name => `https://github.com/github/gh-aw/tree/main/eslint-factory#${name}`); - -/** - * Returns true when the node is a template literal that contains at least one - * interpolated expression. - */ -function isInterpolatedTemplateLiteral(node: TSESTree.Node): boolean { - return node.type === "TemplateLiteral" && node.expressions.length > 0; -} +type ExecMethodName = "exec" | "getExecOutput"; /** * Returns true when the node is a purely static expression (no runtime - * interpolation): a string literal, a no-expression template literal, or a - * binary `+` of two static expressions. + * interpolation): a literal, a no-expression template literal, or a binary + * `+` of two static expressions. */ -function isStaticExpression(node: TSESTree.Node): boolean { - if (node.type === "Literal") return typeof node.value === "string"; +function isStaticExpression(node: TSESTree.Expression): boolean { + if (node.type === "Literal") return true; if (node.type === "TemplateLiteral") return node.expressions.length === 0; if (node.type === "BinaryExpression" && node.operator === "+") { return isStaticExpression(node.left) && isStaticExpression(node.right); @@ -28,7 +21,7 @@ function isStaticExpression(node: TSESTree.Node): boolean { * Returns true when the node is a dynamic string concatenation (binary `+` * that is not entirely static). */ -function isDynamicStringConcatenation(node: TSESTree.Node): boolean { +function isDynamicStringConcatenation(node: TSESTree.Expression): boolean { return node.type === "BinaryExpression" && node.operator === "+" && !isStaticExpression(node); } @@ -36,8 +29,8 @@ function isDynamicStringConcatenation(node: TSESTree.Node): boolean { * Returns the display kind string for the problematic first argument, or null * when the argument is not one of the flagged shapes. */ -function getDynamicCommandKind(node: TSESTree.Node): string | null { - if (isInterpolatedTemplateLiteral(node)) return "interpolated template literal"; +function getDynamicCommandKind(node: TSESTree.Expression): string | null { + if (node.type === "TemplateLiteral" && node.expressions.length > 0) return "interpolated template literal"; if (isDynamicStringConcatenation(node)) return "dynamic string concatenation"; return null; } @@ -49,16 +42,18 @@ function getDynamicCommandKind(node: TSESTree.Node): string | null { * Recognized shapes: * exec.exec(cmd, args?, opts?) * exec.getExecOutput(cmd, args?, opts?) + * + * This rule intentionally matches only the `exec` global injected by + * github-script in CommonJS action scripts. */ -function isExecCall(node: TSESTree.CallExpression): boolean { +function resolveExecMethod(node: TSESTree.CallExpression): ExecMethodName | null { const callee = node.callee; - if (callee.type !== AST_NODE_TYPES.MemberExpression) return false; - if (callee.computed) return false; + if (callee.type !== AST_NODE_TYPES.MemberExpression || callee.computed) return null; const obj = callee.object; const prop = callee.property; - if (obj.type !== AST_NODE_TYPES.Identifier || obj.name !== "exec") return false; - if (prop.type !== AST_NODE_TYPES.Identifier) return false; - return prop.name === "exec" || prop.name === "getExecOutput"; + if (obj.type !== AST_NODE_TYPES.Identifier || obj.name !== "exec") return null; + if (prop.type !== AST_NODE_TYPES.Identifier) return null; + return prop.name === "exec" || prop.name === "getExecOutput" ? prop.name : null; } export const noExecInterpolatedCommandRule = createRule({ @@ -67,7 +62,7 @@ export const noExecInterpolatedCommandRule = createRule({ type: "problem", docs: { description: - "Disallow interpolated template literals or dynamic string concatenation as the first (command) argument of exec.exec() or exec.getExecOutput(). " + + "Disallow interpolated template literals or dynamic string concatenation as the first (command) argument of github-script's injected exec.exec() or exec.getExecOutput() calls in CommonJS action scripts. " + "The @actions/exec runner splits the command string by spaces internally; variables containing spaces silently break argument boundaries. " + "Pass a static command string and put all arguments in the second array parameter instead: exec.exec('git', [arg1, arg2]).", }, @@ -75,17 +70,18 @@ export const noExecInterpolatedCommandRule = createRule({ messages: { interpolatedCommand: "Avoid passing a {{kind}} as the exec command — @actions/exec splits the command string by spaces, so values containing spaces silently break argument boundaries. " + - "Use a static command string and pass all arguments in the args array: exec.exec('git', ['checkout', branchName]).", + "Use a static command string and pass all arguments in the args array, preserving the current method: exec.{{method}}('git', ['checkout', branchName]).", }, }, defaultOptions: [], create(context) { return { CallExpression(node) { - if (!isExecCall(node)) return; + const method = resolveExecMethod(node); + if (!method) return; const firstArg = node.arguments[0]; - if (!firstArg) return; + if (!firstArg || firstArg.type === AST_NODE_TYPES.SpreadElement) return; const kind = getDynamicCommandKind(firstArg); if (!kind) return; @@ -93,7 +89,7 @@ export const noExecInterpolatedCommandRule = createRule({ context.report({ node: firstArg, messageId: "interpolatedCommand", - data: { kind }, + data: { kind, method }, }); }, }; diff --git a/pkg/linters/README.md b/pkg/linters/README.md index 2af2f13dc91..e803c381b3a 100644 --- a/pkg/linters/README.md +++ b/pkg/linters/README.md @@ -55,6 +55,7 @@ This package currently provides custom Go analyzers in the following subpackages - `timeafterleak` — reports `time.After` calls used as the channel-receive expression in a `select` case inside a `for` or `range` loop that leak a timer channel on each iteration when another case fires first. - `timesleepnocontext` — reports `time.Sleep` calls inside functions that already receive a `context.Context`, where a context-aware `select` should be used instead. - `tolowerequalfold` — reports case-insensitive string comparisons using `strings.ToLower`/`ToUpper` that should use `strings.EqualFold`. +- `trimleftright` — reports `strings.TrimLeft`/`TrimRight` calls with multi-character literal cutsets where `TrimPrefix`/`TrimSuffix` was likely intended. - `uncheckedtypeassertion` — reports single-value type assertions where unchecked panics are possible. - `wgdonenotdeferred` — reports non-deferred `sync.WaitGroup.Done()` calls that can deadlock on panics or early returns. - `writebytestring` — reports `w.Write([]byte(s))` calls where `s` is a string, which can be replaced with `io.WriteString` to avoid an unnecessary `[]byte` allocation. @@ -115,6 +116,7 @@ This package currently provides custom Go analyzers in the following subpackages | `timeafterleak` | Custom `go/analysis` analyzer that flags `time.After` in `select` cases inside loops that leak a timer channel on each iteration when another case fires first | | `timesleepnocontext` | Custom `go/analysis` analyzer that flags `time.Sleep` calls in context-aware functions | | `tolowerequalfold` | Custom `go/analysis` analyzer that flags case-insensitive comparisons via `strings.ToLower`/`ToUpper` that should use `strings.EqualFold` | +| `trimleftright` | Custom `go/analysis` analyzer that flags `strings.TrimLeft`/`TrimRight` with multi-character literal cutsets where `TrimPrefix`/`TrimSuffix` was likely intended | | `uncheckedtypeassertion` | Custom `go/analysis` analyzer that flags unchecked single-value type assertions | | `wgdonenotdeferred` | Custom `go/analysis` analyzer that flags non-deferred `sync.WaitGroup.Done()` calls | | `writebytestring` | Custom `go/analysis` analyzer that flags `w.Write([]byte(s))` calls where `s` is a string that can be replaced with `io.WriteString` | @@ -154,6 +156,7 @@ import ( "github.com/github/gh-aw/pkg/linters/sprintfint" "github.com/github/gh-aw/pkg/linters/ssljson" "github.com/github/gh-aw/pkg/linters/timesleepnocontext" + "github.com/github/gh-aw/pkg/linters/trimleftright" ) // Use with multichecker, singlechecker, or custom go/analysis driver. @@ -181,6 +184,7 @@ _ = sortslice.Analyzer _ = sprintfint.Analyzer _ = ssljson.Analyzer _ = timesleepnocontext.Analyzer +_ = trimleftright.Analyzer ``` ## Dependencies @@ -234,6 +238,7 @@ _ = timesleepnocontext.Analyzer - `github.com/github/gh-aw/pkg/linters/timeafterleak` — time-after-leak analyzer subpackage - `github.com/github/gh-aw/pkg/linters/timesleepnocontext` — time-sleep-no-context analyzer subpackage - `github.com/github/gh-aw/pkg/linters/tolowerequalfold` — to-lower-equal-fold analyzer subpackage +- `github.com/github/gh-aw/pkg/linters/trimleftright` — trim-left-right analyzer subpackage - `github.com/github/gh-aw/pkg/linters/uncheckedtypeassertion` — unchecked-type-assertion analyzer subpackage - `github.com/github/gh-aw/pkg/linters/wgdonenotdeferred` — wg-done-not-deferred analyzer subpackage - `github.com/github/gh-aw/pkg/linters/writebytestring` — write-byte-string analyzer subpackage