From 04dc03c3af4d963706dfcd04f0c0bf3ab71ad99d Mon Sep 17 00:00:00 2001 From: Jamkris Date: Thu, 14 May 2026 11:17:46 +0900 Subject: [PATCH] feat(lib): add extractSubshellGroups for plain (...) subshells MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `extractCommandSubstitutions` only walks `$(...)` and backticks — the two shell constructs whose bodies are captured as strings. Bash also has plain `(...)` subshells (e.g. `(npm run dev)`), where the body executes in a child shell but is not value-captured. Our PreToolUse hooks need to peer inside those too, because a `(...)` group bypasses the top-level segment splitter just like `$(...)` does. This commit adds a sibling extractor with the same conventions as `extractCommandSubstitutions`: - single quotes literal — `'(npm run dev)'` is a string, ignored - double quotes literal for parens — `"(npm run dev)"` is a string (bash only honors `$(...)`, not bare `(...)`, inside double quotes) - skips `$(...)` and backtick spans so we don't double-extract bodies the other helper already handles - recurses into its own bodies for nested groups No consumer yet; the next commit wires both extractors into `scripts/hooks/pre-bash-dev-server-block.js` to close the subshell bypass surface. --- scripts/lib/shell-substitution.js | 112 +++++++++++++++++++++++++++++- 1 file changed, 111 insertions(+), 1 deletion(-) diff --git a/scripts/lib/shell-substitution.js b/scripts/lib/shell-substitution.js index aed1beae..0921de1d 100644 --- a/scripts/lib/shell-substitution.js +++ b/scripts/lib/shell-substitution.js @@ -106,4 +106,114 @@ function extractCommandSubstitutions(input) { return substitutions; } -module.exports = { extractCommandSubstitutions }; +/** + * Extract bodies of plain `(...)` subshell groups. + * + * Bash treats `(npm run dev)` as a subshell that executes its contents, but + * the regex-light segment splitters used by our PreToolUse hooks don't peer + * inside those parens. This helper finds top-level `(...)` groups (skipping + * `$(...)` command substitutions and backticks, which `extractCommandSubstitutions` + * already covers) and returns each body, recursing for nested groups. + * + * Quote semantics: + * - Single quotes are literal: `'( ... )'` is a string, not a subshell. + * - Double quotes are literal *for parens*: `"( ... )"` is a string too — + * bash only honors `$( )` inside double quotes, not bare `( )`. + * + * @param {string} input + * @returns {string[]} + */ +function extractSubshellGroups(input) { + const source = String(input || ''); + const groups = []; + let inSingle = false; + let inDouble = false; + + for (let i = 0; i < source.length; i++) { + const ch = source[i]; + const prev = source[i - 1]; + + if (ch === '\\' && !inSingle) { + i += 1; + continue; + } + + if (ch === "'" && !inDouble && prev !== '\\') { + inSingle = !inSingle; + continue; + } + + if (ch === '"' && !inSingle && prev !== '\\') { + inDouble = !inDouble; + continue; + } + + if (inSingle || inDouble) { + continue; + } + + if (ch === '$' && source[i + 1] === '(') { + let depth = 1; + i += 2; + while (i < source.length && depth > 0) { + const inner = source[i]; + if (inner === '\\') { + i += 2; + continue; + } + if (inner === '(') depth += 1; + else if (inner === ')') depth -= 1; + i += 1; + } + i -= 1; + continue; + } + + if (ch === '`') { + i += 1; + while (i < source.length && source[i] !== '`') { + if (source[i] === '\\' && i + 1 < source.length) { + i += 2; + continue; + } + i += 1; + } + continue; + } + + if (ch === '(') { + let depth = 1; + let body = ''; + i += 1; + while (i < source.length && depth > 0) { + const inner = source[i]; + if (inner === '\\') { + body += inner; + if (i + 1 < source.length) { + body += source[i + 1]; + i += 2; + continue; + } + } + if (inner === '(') { + depth += 1; + } else if (inner === ')') { + depth -= 1; + if (depth === 0) { + break; + } + } + body += inner; + i += 1; + } + if (body.trim()) { + groups.push(body); + groups.push(...extractSubshellGroups(body)); + } + } + } + + return groups; +} + +module.exports = { extractCommandSubstitutions, extractSubshellGroups };