|
30 | 30 | "--list", "-l") |
31 | 31 |
|
32 | 32 |
|
| 33 | +_HEREDOC_START = re.compile( |
| 34 | + r"(?<![<])<<(?!<)-?\s*(?:'([^']+)'|\"([^\"]+)\"|([A-Za-z_][A-Za-z0-9_]*))" |
| 35 | +) |
| 36 | +_SHELL_INTERPRETER_NAMES = ("bash", "sh", "zsh", "dash", "ksh") |
| 37 | +_NON_SHELL_INTERPRETER_NAMES = ("python", "python3", "node") |
| 38 | + |
| 39 | + |
| 40 | +def _heredoc_owner_program(command: str, start: int) -> str: |
| 41 | + """heredoc 重定向所在段的程序名(回溯到最近的控制符边界)。""" |
| 42 | + seg_start = 0 |
| 43 | + for i in range(start - 1, -1, -1): |
| 44 | + if command[i] in (";", "\n"): |
| 45 | + seg_start = i + 1 |
| 46 | + break |
| 47 | + if i >= 1 and command[i - 1:i + 1] in ("&&", "||"): |
| 48 | + seg_start = i + 1 |
| 49 | + break |
| 50 | + head = command[seg_start:start] |
| 51 | + tokens = head.replace("&&", " ").split() |
| 52 | + return tokens[0].rsplit("/", 1)[-1].strip("\"'") if tokens else "" |
| 53 | + |
| 54 | + |
| 55 | +def mask_heredoc_bodies(command: str) -> str: |
| 56 | + """heredoc 正文按语义遮蔽为保留换行的空白(fix-release-chain 遗留敞口)。 |
| 57 | +
|
| 58 | + 三类语义(2026-09-23 实测区分,别混): |
| 59 | + - 数据程序 + 引号定界符(`cat <<'EOF'`):正文全字面量 → 全遮蔽—— |
| 60 | + 模板字符串/帮助文本/文档样例的 git 字面量曾被切段误伤成真实副作用; |
| 61 | + - 数据程序 + 无引号(`cat <<EOF`):正文会做命令替换展开, |
| 62 | + `$(git push)`/反引号**会被外层真实执行**(既有实测静默放行向量)—— |
| 63 | + 遮蔽正文但保留替换跨度,交给 _flatten_substitutions 展开扫描; |
| 64 | + - 解释器定界:bash/sh 正文是内层 shell 代码(不遮蔽,保留切段建模); |
| 65 | + python/node 正文不是 shell 语法(全遮蔽杀误报;其 subprocess 调用形态 |
| 66 | + 由 dynamic_git_effects 在脚本文件路径上归因,stdin 形态为已声明边界)。 |
| 67 | + 幂等:已遮蔽的正文不再含定界符结构。 |
| 68 | + """ |
| 69 | + out = list(command) |
| 70 | + for match in _HEREDOC_START.finditer(command): |
| 71 | + delim = match.group(1) or match.group(2) or match.group(3) |
| 72 | + owner = _heredoc_owner_program(command, match.start()) |
| 73 | + if owner in _SHELL_INTERPRETER_NAMES: |
| 74 | + continue |
| 75 | + body_start = command.find("\n", match.end()) |
| 76 | + if body_start < 0: |
| 77 | + continue |
| 78 | + end_match = re.compile(rf"(?m)^[ \t]*{re.escape(delim)}[ \t]*$").search( |
| 79 | + command, body_start + 1) |
| 80 | + body_end = end_match.start() if end_match else len(command) |
| 81 | + keep_subs = (match.group(1) is None and match.group(2) is None |
| 82 | + and owner not in _NON_SHELL_INTERPRETER_NAMES) |
| 83 | + if keep_subs: |
| 84 | + # 无引号数据正文:保留 $(...) 与反引号跨度(其内层文本会被真实执行) |
| 85 | + i = body_start + 1 |
| 86 | + while i < body_end: |
| 87 | + if command[i:i + 2] == "$(": |
| 88 | + depth, j = 1, i + 2 |
| 89 | + while j < body_end and depth: |
| 90 | + if command[j] == "(": |
| 91 | + depth += 1 |
| 92 | + elif command[j] == ")": |
| 93 | + depth -= 1 |
| 94 | + j += 1 |
| 95 | + i = j |
| 96 | + continue |
| 97 | + if command[i] == "`": |
| 98 | + j = command.find("`", i + 1, body_end) |
| 99 | + i = (j + 1) if j >= 0 else body_end |
| 100 | + continue |
| 101 | + if out[i] != "\n": |
| 102 | + out[i] = " " |
| 103 | + i += 1 |
| 104 | + else: |
| 105 | + for i in range(body_start + 1, body_end): |
| 106 | + if out[i] != "\n": |
| 107 | + out[i] = " " |
| 108 | + return "".join(out) |
| 109 | + |
| 110 | + |
33 | 111 | def split_shell_segments(command: str) -> list[tuple[str, str | None]]: |
34 | 112 | """只按未引用、未转义的 Shell 控制符切段,保留前置分隔符。 |
35 | 113 |
|
36 | 114 | 这不是完整 Shell 解释器;嵌套替换由 `_flatten_substitutions` 另行展开。 |
37 | 115 | 引号内的 `;`/`&&` 和 `\\;` 是参数数据,不能合成 git config 豁免。 |
38 | 116 | """ |
| 117 | + command = mask_heredoc_bodies(command) |
39 | 118 | segments: list[tuple[str, str | None]] = [] |
40 | 119 | start = 0 |
41 | 120 | before: str | None = None |
@@ -79,6 +158,7 @@ def _flatten_substitutions(command: str) -> str: |
79 | 158 | 能力边界:单引号内的字面量(`'$(git push)'` 不执行)不做引号语义区分, |
80 | 159 | 宁可多拦不漏拦——误拦方向由 lint 门禁自身的 delta 面兜底(不改文件不红)。 |
81 | 160 | """ |
| 161 | + command = mask_heredoc_bodies(command) |
82 | 162 | parts: list[str] = [] |
83 | 163 | queue: list[str] = [command] |
84 | 164 | seen = 0 |
|
0 commit comments