@gavinc-cn/dsh-path-rules 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1078 @@
1
+ // dsh-path-rules 的 shell 分析层(纯函数)。
2
+ //
3
+ // 分两层:
4
+ // * tokenizeShell —— 词法层:按 shell 规则切段/切词,收集重定向目标、输入目标与命令替换;
5
+ // * analyzeCommand —— 语义层(Task 5/6 补):把命令表映射成 reads/writes,判不出的标记 unanalyzable。
6
+ //
7
+ // 保守原则:任何"看不懂"的成分都不猜,一律交给上层的 unanalyzable 策略处理
8
+ // (spec §5.3:宁可拒绝并让模型改写成可判定形式,也不放行一个可能越权的命令)。
9
+
10
+ import { basename, resolve } from 'node:path'
11
+
12
+ /** 新建一个空的段。`expansion` 标记"本段含真实展开的 `$`"(引号/转义里的字面 `$` 不算)。 */
13
+ function newSegment() {
14
+ return { words: [], redirects: [], inputs: [], substitutions: [], expansion: false }
15
+ }
16
+
17
+ /**
18
+ * 把一个 shell 命令切分成段(管道/`;`/`&&`/`||`/子壳/换行分隔)。
19
+ *
20
+ * 段的形状:`{ words, redirects, inputs, substitutions, expansion }`
21
+ * * `words` —— 普通词(引号内的空格不切词,转义已解码);
22
+ * * `redirects` —— 写重定向目标(`>`、`>>`、`>|`、`n>`、`n>>`、`&>`、`&>>`);
23
+ * * `inputs` —— 读重定向目标(`<`)—— 黑名单读必须能被拦到,故单独收集;
24
+ * * `substitutions` —— `$(...)` / 反引号的内层命令文本,供递归分析;
25
+ * * `expansion` —— 段内是否有真实展开的 `$`(单引号/转义里的字面 `$` 不算)。
26
+ *
27
+ * `2>&1`、`n>&m` 这种 fd 复制、以及 `<<`/`<<<` 的分隔符都**不是**路径目标;
28
+ * heredoc 的正文在物理换行后整体跳过,不产出任何词。
29
+ *
30
+ * @param command - 原始命令文本。
31
+ * @returns `{ segments, error }`:无法解析时 `error = { reason, fragment }`(此时 `segments` 是已扫出的部分)。
32
+ */
33
+ export function tokenizeShell(command) {
34
+ const text = String(command ?? '')
35
+ const segments = []
36
+ let seg = newSegment()
37
+ let word = ''
38
+ let hasWord = false
39
+ /** 待接收目标的运算符:'out'(写)| 'in'(读)| 'heredoc'(记录分隔符)| 'herestring'(吞掉一个词)。 */
40
+ let pending = null
41
+ /** 当前 heredoc 是否为 `<<-`(允许行首 tab)。 */
42
+ let pendingStripTabs = false
43
+ /** 当前命令行上待跳过的 heredoc 列表(物理换行后按顺序吞掉正文)。 */
44
+ let heredocs = []
45
+ let error = null
46
+ let i = 0
47
+
48
+ const append = (ch) => {
49
+ word += ch
50
+ hasWord = true
51
+ }
52
+
53
+ /** 收尾当前词:按 pending 决定它落到哪一类。 */
54
+ const endWord = () => {
55
+ if (!hasWord) return
56
+ if (pending === 'out') seg.redirects.push(word)
57
+ else if (pending === 'in') seg.inputs.push(word)
58
+ else if (pending === 'heredoc') {
59
+ // heredoc 分隔符不是路径;正文在物理换行后整体跳过
60
+ heredocs.push({ delimiter: word, stripTabs: pendingStripTabs })
61
+ } else if (pending === 'herestring') {
62
+ // herestring 的词是数据而不是路径目标,直接吞掉
63
+ } else seg.words.push(word)
64
+ pending = null
65
+ pendingStripTabs = false
66
+ word = ''
67
+ hasWord = false
68
+ }
69
+
70
+ /** 收尾当前段(空段丢弃)。 */
71
+ const endSegment = () => {
72
+ endWord()
73
+ if (seg.words.length > 0 || seg.redirects.length > 0 || seg.inputs.length > 0 || seg.substitutions.length > 0) {
74
+ segments.push(seg)
75
+ }
76
+ seg = newSegment()
77
+ pending = null
78
+ pendingStripTabs = false
79
+ }
80
+
81
+ const fail = (reason, at) => {
82
+ if (error === null) error = { reason, fragment: text.slice(Math.max(0, at - 12), at + 12) }
83
+ i = text.length
84
+ }
85
+
86
+ /**
87
+ * 跳过 heredoc 正文:从 `start`(物理换行之后)出发,按声明顺序找到每个分隔符所在的
88
+ * "单独成行"的结束行;正文与结束行都不产出任何词。
89
+ *
90
+ * `<<-` 允许结束行以 tab 开头(先剥行首 tab,再按 trim 比较)。
91
+ * 某个分隔符始终没出现时记 `error`(未闭合 heredoc),并返回文本末尾。
92
+ */
93
+ const skipHeredocBodies = (start, pendingDocs) => {
94
+ let j = start
95
+ for (const doc of pendingDocs) {
96
+ let found = false
97
+ while (j <= text.length) {
98
+ const lineEnd = text.indexOf('\n', j)
99
+ const end = lineEnd === -1 ? text.length : lineEnd
100
+ let line = text.slice(j, end)
101
+ if (doc.stripTabs) line = line.replace(/^\t+/, '')
102
+ if (line.trim() === doc.delimiter) {
103
+ found = true
104
+ j = end === text.length ? text.length : end + 1
105
+ break
106
+ }
107
+ if (end === text.length) break
108
+ j = end + 1
109
+ }
110
+ if (!found) {
111
+ fail(`heredoc 未闭合(缺少分隔符 ${doc.delimiter})`, j)
112
+ return text.length
113
+ }
114
+ }
115
+ return j
116
+ }
117
+
118
+ /** 读 `$( ... )` 的内层文本(括号配平,跳过引号内部)。 */
119
+ const readParen = (start) => {
120
+ let depth = 0
121
+ let j = start
122
+ let out = ''
123
+ let quote = null
124
+ while (j < text.length) {
125
+ const ch = text[j]
126
+ if (quote !== null) {
127
+ out += ch
128
+ if (ch === '\\' && quote === '"') {
129
+ out += text[j + 1] ?? ''
130
+ j += 2
131
+ continue
132
+ }
133
+ if (ch === quote) quote = null
134
+ j += 1
135
+ continue
136
+ }
137
+ if (ch === "'" || ch === '"') {
138
+ quote = ch
139
+ out += ch
140
+ j += 1
141
+ continue
142
+ }
143
+ if (ch === '(') {
144
+ depth += 1
145
+ out += ch
146
+ j += 1
147
+ continue
148
+ }
149
+ if (ch === ')') {
150
+ if (depth === 0) return { body: out, next: j + 1 }
151
+ depth -= 1
152
+ out += ch
153
+ j += 1
154
+ continue
155
+ }
156
+ out += ch
157
+ j += 1
158
+ }
159
+ return null
160
+ }
161
+
162
+ /** 读反引号的内层文本。 */
163
+ const readBacktick = (start) => {
164
+ let j = start
165
+ let out = ''
166
+ while (j < text.length) {
167
+ const ch = text[j]
168
+ if (ch === '\\') {
169
+ out += text[j + 1] ?? ''
170
+ j += 2
171
+ continue
172
+ }
173
+ if (ch === '`') return { body: out, next: j + 1 }
174
+ out += ch
175
+ j += 1
176
+ }
177
+ return null
178
+ }
179
+
180
+ while (i < text.length) {
181
+ const ch = text[i]
182
+
183
+ // 转义:反斜杠把下一个字符原样并入当前词
184
+ if (ch === '\\') {
185
+ if (i + 1 >= text.length) {
186
+ fail('命令以孤立的反斜杠结尾', i)
187
+ break
188
+ }
189
+ append(text[i + 1])
190
+ i += 2
191
+ continue
192
+ }
193
+
194
+ // 单引号:内部一切原样
195
+ if (ch === "'") {
196
+ const end = text.indexOf("'", i + 1)
197
+ if (end === -1) {
198
+ fail('单引号未闭合', i)
199
+ break
200
+ }
201
+ for (let k = i + 1; k < end; k += 1) append(text[k])
202
+ i = end + 1
203
+ continue
204
+ }
205
+
206
+ // $'...':ANSI-C 引号(保守处理:解码反斜杠,不做字符转义展开)
207
+ if (ch === '$' && text[i + 1] === "'") {
208
+ let j = i + 2
209
+ let closed = false
210
+ while (j < text.length) {
211
+ if (text[j] === '\\') {
212
+ append(text[j + 1] ?? '')
213
+ j += 2
214
+ continue
215
+ }
216
+ if (text[j] === "'") {
217
+ closed = true
218
+ break
219
+ }
220
+ append(text[j])
221
+ j += 1
222
+ }
223
+ if (!closed) {
224
+ fail("$'...' 引号未闭合", i)
225
+ break
226
+ }
227
+ i = j + 1
228
+ continue
229
+ }
230
+
231
+ // 双引号:转义受限,但命令替换仍然生效
232
+ if (ch === '"') {
233
+ let j = i + 1
234
+ let closed = false
235
+ while (j < text.length) {
236
+ const c = text[j]
237
+ if (c === '\\') {
238
+ const next = text[j + 1]
239
+ if (next === '"' || next === '\\' || next === '$' || next === '`') {
240
+ append(next)
241
+ j += 2
242
+ continue
243
+ }
244
+ append(c)
245
+ j += 1
246
+ continue
247
+ }
248
+ if (c === '`') {
249
+ const sub = readBacktick(j + 1)
250
+ if (sub === null) {
251
+ fail('反引号未闭合', j)
252
+ break
253
+ }
254
+ seg.substitutions.push(sub.body)
255
+ j = sub.next
256
+ continue
257
+ }
258
+ if (c === '$' && text[j + 1] === '(') {
259
+ const sub = readParen(j + 2)
260
+ if (sub === null) {
261
+ fail('命令替换 $( 未闭合', j)
262
+ break
263
+ }
264
+ seg.substitutions.push(sub.body)
265
+ j = sub.next
266
+ continue
267
+ }
268
+ if (c === '"') {
269
+ closed = true
270
+ break
271
+ }
272
+ // 双引号内的 `$` 是真实展开(`$(...)`/反引号已单独递归,`\$` 已在上面解码成字面)
273
+ if (c === '$') seg.expansion = true
274
+ append(c)
275
+ j += 1
276
+ }
277
+ if (error !== null) break
278
+ if (!closed) {
279
+ fail('双引号未闭合', i)
280
+ break
281
+ }
282
+ i = j + 1
283
+ continue
284
+ }
285
+
286
+ // 命令替换(引号外)
287
+ if (ch === '`') {
288
+ const sub = readBacktick(i + 1)
289
+ if (sub === null) {
290
+ fail('反引号未闭合', i)
291
+ break
292
+ }
293
+ seg.substitutions.push(sub.body)
294
+ i = sub.next
295
+ continue
296
+ }
297
+ if (ch === '$' && text[i + 1] === '(') {
298
+ const sub = readParen(i + 2)
299
+ if (sub === null) {
300
+ fail('命令替换 $( 未闭合', i)
301
+ break
302
+ }
303
+ seg.substitutions.push(sub.body)
304
+ i = sub.next
305
+ continue
306
+ }
307
+
308
+ // 空白:结束当前词
309
+ if (ch === ' ' || ch === '\t') {
310
+ endWord()
311
+ i += 1
312
+ continue
313
+ }
314
+
315
+ // 物理换行:先收尾当前段;若有 heredoc,正文从这里开始整体跳过
316
+ if (ch === '\n') {
317
+ if (pending === 'heredoc' && !hasWord) {
318
+ fail('heredoc 缺少分隔符', i)
319
+ break
320
+ }
321
+ endSegment()
322
+ i += 1
323
+ if (heredocs.length > 0) {
324
+ i = skipHeredocBodies(i, heredocs)
325
+ heredocs = []
326
+ if (error !== null) break
327
+ }
328
+ continue
329
+ }
330
+
331
+ // 其余段分隔符
332
+ if (ch === ';' || ch === '(' || ch === ')') {
333
+ endSegment()
334
+ i += 1
335
+ continue
336
+ }
337
+ if (ch === '|') {
338
+ if (text[i + 1] === '|') i += 1
339
+ endSegment()
340
+ i += 1
341
+ continue
342
+ }
343
+ if (ch === '&') {
344
+ if (text[i + 1] === '&') {
345
+ endSegment()
346
+ i += 2
347
+ continue
348
+ }
349
+ if (text[i + 1] === '>') {
350
+ endWord()
351
+ pending = 'out'
352
+ i += text[i + 2] === '>' ? 3 : 2
353
+ continue
354
+ }
355
+ endSegment()
356
+ i += 1
357
+ continue
358
+ }
359
+
360
+ // 写重定向
361
+ if (ch === '>') {
362
+ if (hasWord && /^\d+$/.test(word)) {
363
+ word = ''
364
+ hasWord = false
365
+ } else {
366
+ endWord()
367
+ }
368
+ let j = i + 1
369
+ if (text[j] === '>' || text[j] === '|') j += 1
370
+ if (text[j] === '&') {
371
+ // fd 复制(2>&1、>&2):不是路径目标
372
+ j += 1
373
+ while (j < text.length && /[0-9-]/.test(text[j])) j += 1
374
+ i = j
375
+ continue
376
+ }
377
+ pending = 'out'
378
+ i = j
379
+ continue
380
+ }
381
+
382
+ // 读重定向 / heredoc / herestring
383
+ if (ch === '<') {
384
+ endWord()
385
+ if (text[i + 1] === '<') {
386
+ if (text[i + 2] === '<') {
387
+ // herestring:只吞掉后面一个词(词是数据,不是路径目标)
388
+ pending = 'herestring'
389
+ i += 3
390
+ continue
391
+ }
392
+ let j = i + 2
393
+ pendingStripTabs = false
394
+ if (text[j] === '-') {
395
+ // `<<-`:结束行允许行首 tab
396
+ pendingStripTabs = true
397
+ j += 1
398
+ }
399
+ pending = 'heredoc'
400
+ i = j
401
+ continue
402
+ }
403
+ pending = 'in'
404
+ i += 1
405
+ continue
406
+ }
407
+
408
+ if (ch === '$') seg.expansion = true
409
+ append(ch)
410
+ i += 1
411
+ }
412
+
413
+ if (error !== null) return { segments, error }
414
+ endSegment()
415
+ return { segments, error: null }
416
+ }
417
+
418
+ // ─────────────────────────── 语义层(命令表) ───────────────────────────
419
+
420
+ /** 递归嵌套上限(`$( )`、反引号、`bash -c` 共用)。 */
421
+ const MAX_NESTING = 3
422
+
423
+ /**
424
+ * 只读命令:命中即证明"没有写副作用"。
425
+ * `sed`/`find`/`curl`/`wget`/`sort`/`uniq`/`less`/`more`/`env` 等能写或能起程序的命令不在此表。
426
+ * 注意 `awk`/`xargs`/`eval` 这类"程序里还能再写文件"的命令**故意不入表** ⇒ 走 unanalyzable。
427
+ */
428
+ const READ_ONLY_COMMANDS = new Set([
429
+ 'ls', 'cat', 'head', 'tail', 'wc', 'stat', 'file', 'du', 'df', 'tree', 'realpath', 'readlink',
430
+ 'grep', 'rg', 'cut', 'tr', 'diff', 'cmp', 'echo', 'printf', 'printenv',
431
+ 'date', 'pwd', 'which', 'type', 'whoami', 'id', 'uname', 'ps', 'hostname', 'uptime', 'free',
432
+ 'seq', 'sleep', 'true', 'false', 'test', 'expr', 'basename', 'dirname', 'jq', 'md5sum',
433
+ 'sha256sum',
434
+ ])
435
+
436
+ /** 只读命令里确实带文件路径参数的那些(其余命令的参数不是路径,不做黑名单判定)。 */
437
+ const PATH_READ_COMMANDS = new Set([
438
+ 'ls', 'cat', 'head', 'tail', 'wc', 'stat', 'file', 'du', 'df', 'tree', 'realpath', 'readlink',
439
+ 'cut', 'diff', 'cmp', 'grep', 'rg', 'basename', 'dirname', 'jq', 'md5sum', 'sha256sum',
440
+ ])
441
+
442
+ /** 命令取值的选项(`-e script` 这种,后面那个词不是路径)。 */
443
+ const GREP_VALUE_OPTIONS = new Set(['-e', '--regexp', '-f', '--file', '-m', '--max-count', '-A', '-B', '-C', '--include', '--exclude', '--exclude-dir'])
444
+ const SED_VALUE_OPTIONS = new Set(['-e', '--expression', '-f', '--file'])
445
+ const CURL_VALUE_OPTIONS = new Set(['-o', '--output', '-H', '--header', '-d', '--data', '-X', '--request', '-u', '--user', '-A', '--user-agent'])
446
+ const GIT_VALUE_OPTIONS = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--exec-path'])
447
+ const ENV_VALUE_OPTIONS = new Set(['-u', '--unset', '-C', '--chdir'])
448
+ const ENV_FLAG_OPTIONS = new Set(['-', '-i', '--ignore-environment', '-0', '--null', '-v', '--debug', '--help', '--version'])
449
+ const GIT_WORKTREE_ADD_VALUE_OPTIONS = new Set(['-b', '-B', '--reason', '--prefix'])
450
+ const WGET_VALUE_OPTIONS = new Set([
451
+ '-O', '--output-document', '-o', '--output-file', '-P', '--directory-prefix',
452
+ '-a', '--append-output', '-e', '--execute', '-t', '--tries', '-T', '--timeout',
453
+ '-w', '--wait', '--waitretry', '--limit-rate', '-U', '--user-agent',
454
+ '--header', '--post-data', '--post-file', '--body-data', '--body-file',
455
+ '-Y', '--proxy', '--http-user', '--http-password', '--user', '--password',
456
+ '--ca-certificate', '--certificate', '--private-key', '--bind-address',
457
+ '--dns-timeout', '--connect-timeout', '--read-timeout', '-l', '--level',
458
+ '--referer', '-i', '--input-file', '-B', '--base', '--cut-dirs',
459
+ '-A', '--accept', '-R', '--reject', '-D', '--domains', '-X', '--exclude-directories',
460
+ '--progress', '-Q', '--quota', '-F', '--force-html', '--default-page',
461
+ ])
462
+ const SORT_VALUE_OPTIONS = new Set([
463
+ '-o', '--output', '-k', '--key', '-t', '--field-separator', '-T', '--temporary-directory',
464
+ '-S', '--buffer-size', '--parallel', '--batch-size', '--files0-from', '--sort',
465
+ '--random-source', '--compress-program',
466
+ ])
467
+ const UNIQ_VALUE_OPTIONS = new Set(['-f', '--skip-fields', '-s', '--skip-chars', '-w', '--check-chars'])
468
+ const PAGER_VALUE_OPTIONS = new Set([
469
+ '-o', '--log-file', '-p', '--pattern', '-b', '--buffers', '-h', '--max-back-scroll',
470
+ '-j', '--jump-target', '-x', '--tabs', '-y', '--max-forw-scroll', '-z', '--window',
471
+ '-t', '--tag', '-T', '--tag-file', '-P', '--prompt', '-D', '--color',
472
+ ])
473
+
474
+ /** shell 解释器:`bash -c "..."` 的内层要递归分析。 */
475
+ const SHELL_COMMANDS = new Set(['bash', 'sh', 'zsh', 'dash'])
476
+
477
+ /** git 的只读子命令(其余一律按"写仓库目录"处理,内容层面的规则交给 dsh-git-guard)。 */
478
+ const GIT_READ_SUBCOMMANDS = new Set([
479
+ 'status', 'log', 'diff', 'show', 'blame', 'rev-parse', 'rev-list', 'ls-files', 'ls-remote',
480
+ 'describe', 'shortlog', 'reflog', 'cat-file', 'grep', 'count-objects', 'symbolic-ref',
481
+ 'merge-base', 'show-ref', 'whatchanged', 'var', 'version', 'help',
482
+ ])
483
+
484
+ /** 写命令的路径提取规则:mode = all(所有位置参数)/ last(末位是写、前面是读)/ 专用模式。 */
485
+ const WRITE_COMMANDS = new Map([
486
+ ['touch', { mode: 'all' }],
487
+ ['mkdir', { mode: 'all' }],
488
+ ['rmdir', { mode: 'all' }],
489
+ ['rm', { mode: 'all' }],
490
+ ['truncate', { mode: 'all', valueOptions: ['-s', '--size', '-r', '--reference'] }],
491
+ ['chmod', { mode: 'all', skip: 1 }],
492
+ ['chown', { mode: 'all', skip: 1 }],
493
+ ['chgrp', { mode: 'all', skip: 1 }],
494
+ ['ln', { mode: 'last', leadingReads: true }],
495
+ ['cp', { mode: 'last', leadingReads: true }],
496
+ ['install', { mode: 'last', leadingReads: true }],
497
+ ['mv', { mode: 'all' }],
498
+ ['rsync', { mode: 'all' }],
499
+ ['tee', { mode: 'all' }],
500
+ ['dd', { mode: 'dd' }],
501
+ ['find', { mode: 'find' }],
502
+ ['sed', { mode: 'sed' }],
503
+ ['git', { mode: 'git' }],
504
+ ['curl', { mode: 'curl' }],
505
+ ['wget', { mode: 'wget' }],
506
+ ['sort', { mode: 'sort' }],
507
+ ['uniq', { mode: 'uniq' }],
508
+ ['less', { mode: 'pager' }],
509
+ ['more', { mode: 'pager' }],
510
+ ['env', { mode: 'env' }],
511
+ ])
512
+
513
+ /** 取某个选项的值:支持 `-o val` / `--opt=val` / `-oval`。 */
514
+ function optionValue(words, names) {
515
+ for (let i = 1; i < words.length; i += 1) {
516
+ const word = words[i]
517
+ for (const name of names) {
518
+ if (word === name) return words[i + 1]
519
+ if (word.startsWith(`${name}=`)) return word.slice(name.length + 1)
520
+ if (name.length === 2 && name.startsWith('-') && !name.startsWith('--') && word.startsWith(name) && word.length > 2) {
521
+ return word.slice(name.length)
522
+ }
523
+ }
524
+ }
525
+ return undefined
526
+ }
527
+
528
+ /** 是否出现某个选项(含 `--opt=val` 与 `-oval` 形态)。 */
529
+ function hasOption(words, names) {
530
+ return words.some((word) => names.some((name) => {
531
+ if (word === name) return true
532
+ if (word.startsWith(`${name}=`)) return true
533
+ return name.length === 2 && !name.startsWith('--') && word.startsWith(name) && word.length > 2
534
+ }))
535
+ }
536
+
537
+ /** 位置参数(跳过选项与取值选项的值)。 */
538
+ function positionalsOf(words, valueOptions = new Set()) {
539
+ const out = []
540
+ for (let i = 1; i < words.length; i += 1) {
541
+ const word = words[i]
542
+ if (word.startsWith('-') && word !== '-') {
543
+ if (valueOptions.has(word)) i += 1
544
+ continue
545
+ }
546
+ out.push(word)
547
+ }
548
+ return out
549
+ }
550
+
551
+ /** 相对路径按 cwd 解析;绝对路径原样规范化。 */
552
+ function toAbsolute(word, cwd) {
553
+ return resolve(cwd, word)
554
+ }
555
+
556
+ /** 只读命令贡献的读目标。 */
557
+ function readPathsOf(cmd, words, cwd) {
558
+ if (!PATH_READ_COMMANDS.has(cmd)) return []
559
+ if (cmd === 'grep' || cmd === 'rg') {
560
+ const positionals = positionalsOf(words, GREP_VALUE_OPTIONS)
561
+ const hasPatternOption = hasOption(words, ['-e', '--regexp', '-f', '--file'])
562
+ const paths = hasPatternOption ? positionals : positionals.slice(1)
563
+ return paths.map((word) => toAbsolute(word, cwd))
564
+ }
565
+ return positionalsOf(words).map((word) => toAbsolute(word, cwd))
566
+ }
567
+
568
+ /**
569
+ * 跳过 `env` 开头的环境变量赋值与选项,返回其后的命令词与 `-C/--chdir` 指定的工作目录。
570
+ * 无法安全跳过的选项(如 `-S`)标记 unanalyzable 并返回 null。
571
+ */
572
+ function envCommandWords(words, flag) {
573
+ let i = 1
574
+ let chdir
575
+ while (i < words.length) {
576
+ const word = words[i]
577
+ // 开头的 NAME=value 是环境变量赋值,不是命令
578
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(word)) {
579
+ i += 1
580
+ continue
581
+ }
582
+ if (word === '--') {
583
+ i += 1
584
+ break
585
+ }
586
+ if (ENV_FLAG_OPTIONS.has(word)) {
587
+ i += 1
588
+ continue
589
+ }
590
+ if (word === '-S' || word === '--split-string' || word.startsWith('--split-string=')) {
591
+ // -S 会把一个字符串再拆成命令词,静态展开成本高,保守拒绝
592
+ flag('env -S/--split-string 无法静态判定', word)
593
+ return null
594
+ }
595
+ const eq = word.indexOf('=')
596
+ const name = eq === -1 ? word : word.slice(0, eq)
597
+ if (ENV_VALUE_OPTIONS.has(name)) {
598
+ // `-u NAME` / `--unset=NAME` / `-C DIR` / `--chdir=DIR`
599
+ const value = eq === -1 ? words[i + 1] : word.slice(eq + 1)
600
+ if (name === '-C' || name === '--chdir') chdir = value
601
+ i += eq === -1 ? 2 : 1
602
+ continue
603
+ }
604
+ if ((word.startsWith('-u') || word.startsWith('-C')) && word.length > 2) {
605
+ // `-uNAME` / `-CDIR` 粘写形态
606
+ if (word.startsWith('-C')) chdir = word.slice(2)
607
+ i += 1
608
+ continue
609
+ }
610
+ if (word.startsWith('-') && word.length > 1) {
611
+ flag(`env 的选项无法判定:${word}`, word)
612
+ return null
613
+ }
614
+ break
615
+ }
616
+ return { words: words.slice(i), chdir }
617
+ }
618
+
619
+ /** 跳过 git 的全局选项(含取值),返回子命令在 words 中的下标。 */
620
+ function gitSubcommandIndex(words) {
621
+ let i = 1
622
+ while (i < words.length && words[i].startsWith('-')) {
623
+ const name = words[i].split('=')[0]
624
+ i += GIT_VALUE_OPTIONS.has(name) && !words[i].includes('=') ? 2 : 1
625
+ }
626
+ return i
627
+ }
628
+
629
+ /** 取第一个位置参数(跳过选项与取值选项的值;`--` 之后直接取)。 */
630
+ function firstPositional(words, valueOptions = new Set()) {
631
+ for (let i = 0; i < words.length; i += 1) {
632
+ const word = words[i]
633
+ if (word === '--') return words[i + 1]
634
+ if (word.startsWith('-') && word !== '-') {
635
+ if (valueOptions.has(word)) i += 1
636
+ continue
637
+ }
638
+ return word
639
+ }
640
+ return undefined
641
+ }
642
+
643
+ /**
644
+ * 分析 `git worktree ...`:
645
+ * * `list` 只读(读仓库目录);
646
+ * * `add <path>` / `remove <path>` 写该 path;
647
+ * * `prune` 写仓库目录(改 .git/worktrees 元数据);
648
+ * * 其余子命令保守 unanalyzable。
649
+ */
650
+ function analyzeGitWorktree(words, ctx, acc, flag, repoDir) {
651
+ const index = gitSubcommandIndex(words)
652
+ const action = words[index + 1]
653
+ const rest = words.slice(index + 2)
654
+ if (action === 'list') {
655
+ acc.reads.push(repoDir)
656
+ return
657
+ }
658
+ if (action === 'prune') {
659
+ acc.writes.push(repoDir)
660
+ return
661
+ }
662
+ if (action === 'add' || action === 'remove') {
663
+ const path = firstPositional(rest, action === 'add' ? GIT_WORKTREE_ADD_VALUE_OPTIONS : new Set())
664
+ if (path === undefined) {
665
+ flag(`git worktree ${action} 缺少路径`, words[0])
666
+ return
667
+ }
668
+ acc.writes.push(toAbsolute(path, ctx.cwd))
669
+ return
670
+ }
671
+ flag(`git worktree 子命令无法判定:${action ?? '(无)'}`, words[0])
672
+ }
673
+
674
+ /**
675
+ * 分析一条 shell 命令的读写目标。
676
+ *
677
+ * @param command - 命令文本(可含管道/重定向/命令替换)。
678
+ * @param options - `{ shell: 'bash'|'pwsh', cwd, depth }`;`depth` 为内部递归用。
679
+ * @returns `{ unanalyzable, reads, writes, readOnly }`:
680
+ * `unanalyzable = { reason, fragment }` 表示静态判不出来,由调用方按策略处理(默认拒绝)。
681
+ */
682
+ export function analyzeCommand(command, options = {}) {
683
+ const shell = options.shell === 'pwsh' ? 'pwsh' : 'bash'
684
+ const cwd = typeof options.cwd === 'string' && options.cwd.trim().length > 0 ? options.cwd : process.cwd()
685
+ const depth = Number.isInteger(options.depth) ? options.depth : 0
686
+ const text = String(command ?? '')
687
+ if (depth > MAX_NESTING) {
688
+ return { unanalyzable: { reason: `嵌套超过 ${MAX_NESTING} 层,无法静态判定`, fragment: text }, reads: [], writes: [], readOnly: false }
689
+ }
690
+ const { segments, error } = tokenizeShell(text)
691
+ if (error !== null) return { unanalyzable: error, reads: [], writes: [], readOnly: false }
692
+ if (shell === 'pwsh') return analyzePwsh(segments, { cwd, depth })
693
+ const acc = { reads: [], writes: [], unanalyzable: null }
694
+ for (const seg of segments) {
695
+ analyzeBashSegment(seg, { cwd, depth }, acc)
696
+ if (acc.unanalyzable !== null) break
697
+ }
698
+ const reads = [...new Set(acc.reads)]
699
+ const writes = [...new Set(acc.writes)]
700
+ return { unanalyzable: acc.unanalyzable, reads, writes, readOnly: acc.unanalyzable === null && writes.length === 0 }
701
+ }
702
+
703
+ /** 分析一个段(bash 语义);结果累加到 acc。 */
704
+ function analyzeBashSegment(seg, ctx, acc) {
705
+ const flag = (reason, fragment) => {
706
+ if (acc.unanalyzable === null) acc.unanalyzable = { reason, fragment }
707
+ }
708
+ for (const sub of seg.substitutions) {
709
+ const nested = analyzeCommand(sub, { shell: 'bash', cwd: ctx.cwd, depth: ctx.depth + 1 })
710
+ if (nested.unanalyzable !== null) {
711
+ flag(`命令替换无法判定:${nested.unanalyzable.reason}`, sub)
712
+ return
713
+ }
714
+ acc.reads.push(...nested.reads)
715
+ acc.writes.push(...nested.writes)
716
+ }
717
+ if (seg.expansion === true) {
718
+ // 只有真实展开(引号外/双引号内的 `$`)才判不出来;单引号/转义里的字面 `$` 不算
719
+ const fragment = [...seg.words, ...seg.redirects, ...seg.inputs].find((word) => word.includes('$')) ?? '$'
720
+ flag(`命令含未展开的变量或替换:${fragment}`, fragment)
721
+ return
722
+ }
723
+ for (const input of seg.inputs) acc.reads.push(toAbsolute(input, ctx.cwd))
724
+ for (const redirect of seg.redirects) acc.writes.push(toAbsolute(redirect, ctx.cwd))
725
+ analyzeBashWords(seg.words, ctx, acc, flag)
726
+ }
727
+
728
+ /** 分析一条命令词(words[0] 是程序名);`env` 递归等场景与段分析复用。 */
729
+ function analyzeBashWords(words, ctx, acc, flag) {
730
+ if (words.length === 0) return
731
+ const cmd = basename(words[0])
732
+
733
+ if (SHELL_COMMANDS.has(cmd)) {
734
+ const index = words.indexOf('-c')
735
+ if (index === -1) {
736
+ flag(`${cmd} 未见 -c,无法判定内层命令`, words[0])
737
+ return
738
+ }
739
+ const inner = words.slice(index + 1).join(' ').trim()
740
+ if (inner.length === 0) {
741
+ flag(`${cmd} -c 后没有命令文本`, words[0])
742
+ return
743
+ }
744
+ const nested = analyzeCommand(inner, { shell: 'bash', cwd: ctx.cwd, depth: ctx.depth + 1 })
745
+ if (nested.unanalyzable !== null) {
746
+ flag(`内层命令无法判定:${nested.unanalyzable.reason}`, inner)
747
+ return
748
+ }
749
+ acc.reads.push(...nested.reads)
750
+ acc.writes.push(...nested.writes)
751
+ return
752
+ }
753
+
754
+ if (READ_ONLY_COMMANDS.has(cmd)) {
755
+ acc.reads.push(...readPathsOf(cmd, words, ctx.cwd))
756
+ return
757
+ }
758
+
759
+ const rule = WRITE_COMMANDS.get(cmd)
760
+ if (rule === undefined) {
761
+ flag(`未收录的命令(无法判定读写):${cmd}`, words[0])
762
+ return
763
+ }
764
+
765
+ if (rule.mode === 'env') {
766
+ const parsed = envCommandWords(words, flag)
767
+ if (parsed === null || parsed.words.length === 0) return
768
+ const nestedDepth = ctx.depth + 1
769
+ if (nestedDepth > MAX_NESTING) {
770
+ flag(`嵌套超过 ${MAX_NESTING} 层,无法静态判定`, words[0])
771
+ return
772
+ }
773
+ // `env -C DIR cmd`:内层命令的相对路径以 DIR 为基准
774
+ const nestedCwd = parsed.chdir === undefined ? ctx.cwd : resolve(ctx.cwd, parsed.chdir)
775
+ analyzeBashWords(parsed.words, { cwd: nestedCwd, depth: nestedDepth }, acc, flag)
776
+ return
777
+ }
778
+
779
+ if (rule.mode === 'dd') {
780
+ for (const word of words.slice(1)) {
781
+ if (word.startsWith('of=')) acc.writes.push(toAbsolute(word.slice(3), ctx.cwd))
782
+ else if (word.startsWith('if=')) acc.reads.push(toAbsolute(word.slice(3), ctx.cwd))
783
+ else if (!word.includes('=')) {
784
+ flag(`dd 的位置参数无法判定:${word}`, word)
785
+ return
786
+ }
787
+ }
788
+ return
789
+ }
790
+
791
+ if (rule.mode === 'find') {
792
+ const operands = words.slice(1)
793
+ const firstOption = words.findIndex((word, i) => i > 0 && word.startsWith('-'))
794
+ const startPaths = (firstOption === -1 ? operands : words.slice(1, firstOption)).map((word) => toAbsolute(word, ctx.cwd))
795
+ if (words.some((word) => word.startsWith('-exec') || word === '-ok' || word === '-okdir' || word.startsWith('-fprint'))) {
796
+ flag('find 的 -exec/-fprint 无法静态判定', words[0])
797
+ return
798
+ }
799
+ if (words.includes('-delete')) acc.writes.push(...startPaths)
800
+ else acc.reads.push(...startPaths)
801
+ return
802
+ }
803
+
804
+ if (rule.mode === 'sed') {
805
+ const positionals = positionalsOf(words, SED_VALUE_OPTIONS)
806
+ const hasScriptOption = hasOption(words, ['-e', '--expression', '-f', '--file'])
807
+ const files = hasScriptOption ? positionals : positionals.slice(1)
808
+ const targets = files.map((word) => toAbsolute(word, ctx.cwd))
809
+ // -i 就地改写是写;不带 -i 时文件操作数只是读(之前直接 return 会漏掉黑名单读)
810
+ if (hasOption(words, ['-i', '--in-place'])) acc.writes.push(...targets)
811
+ else acc.reads.push(...targets)
812
+ return
813
+ }
814
+
815
+ if (rule.mode === 'git') {
816
+ const positionals = positionalsOf(words, GIT_VALUE_OPTIONS)
817
+ const sub = positionals[0]
818
+ if (sub === undefined) {
819
+ flag('git 未给出子命令', words[0])
820
+ return
821
+ }
822
+ const repoDir = toAbsolute(optionValue(words, ['-C']) ?? ctx.cwd, ctx.cwd)
823
+ if (sub === 'clone') {
824
+ const dest = positionals[1]
825
+ acc.writes.push(dest === undefined ? repoDir : toAbsolute(dest, ctx.cwd))
826
+ return
827
+ }
828
+ if (sub === 'worktree') {
829
+ analyzeGitWorktree(words, ctx, acc, flag, repoDir)
830
+ return
831
+ }
832
+ const readOnlyForm = GIT_READ_SUBCOMMANDS.has(sub)
833
+ || ((sub === 'branch' || sub === 'tag') && (hasOption(words, ['--list']) || positionals.length === 1))
834
+ || (sub === 'remote' && positionals.length === 1)
835
+ || (sub === 'config' && hasOption(words, ['--get', '--list', '-l']))
836
+ || (sub === 'stash' && (positionals[1] === 'list' || positionals[1] === 'show'))
837
+ if (readOnlyForm) acc.reads.push(repoDir)
838
+ else acc.writes.push(repoDir)
839
+ return
840
+ }
841
+
842
+ if (rule.mode === 'curl') {
843
+ const output = optionValue(words, ['-o', '--output'])
844
+ if (output !== undefined) {
845
+ acc.writes.push(toAbsolute(output, ctx.cwd))
846
+ return
847
+ }
848
+ if (hasOption(words, ['-O', '--remote-name'])) {
849
+ const positionals = positionalsOf(words, CURL_VALUE_OPTIONS)
850
+ const url = positionals[positionals.length - 1]
851
+ if (url === undefined) {
852
+ flag('curl -O 无法确定输出文件名', words[0])
853
+ return
854
+ }
855
+ acc.writes.push(toAbsolute(basename(url), ctx.cwd))
856
+ }
857
+ return
858
+ }
859
+
860
+ if (rule.mode === 'wget') {
861
+ const positionals = positionalsOf(words, WGET_VALUE_OPTIONS)
862
+ const url = positionals[positionals.length - 1]
863
+ if (url === undefined) {
864
+ flag('wget 未给出 URL,无法判定输出目标', words[0])
865
+ return
866
+ }
867
+ const output = optionValue(words, ['-O', '--output-document'])
868
+ const logFile = optionValue(words, ['-o', '--output-file'])
869
+ const prefix = optionValue(words, ['-P', '--directory-prefix'])
870
+ let explicit = false
871
+ if (output !== undefined) {
872
+ explicit = true
873
+ if (output !== '-') acc.writes.push(toAbsolute(output, ctx.cwd))
874
+ }
875
+ if (logFile !== undefined) {
876
+ explicit = true
877
+ if (logFile !== '-') acc.writes.push(toAbsolute(logFile, ctx.cwd))
878
+ }
879
+ if (prefix !== undefined) {
880
+ explicit = true
881
+ acc.writes.push(toAbsolute(prefix, ctx.cwd))
882
+ }
883
+ if (!explicit) {
884
+ // 没有显式输出选项时,wget 把文件存到 cwd 下的 URL basename
885
+ const name = basename(url)
886
+ if (name.length === 0) {
887
+ flag('wget 无法确定输出文件名', url)
888
+ return
889
+ }
890
+ acc.writes.push(toAbsolute(name, ctx.cwd))
891
+ }
892
+ return
893
+ }
894
+
895
+ if (rule.mode === 'sort') {
896
+ const output = optionValue(words, ['-o', '--output'])
897
+ if (output !== undefined) acc.writes.push(toAbsolute(output, ctx.cwd))
898
+ acc.reads.push(...positionalsOf(words, SORT_VALUE_OPTIONS).map((word) => toAbsolute(word, ctx.cwd)))
899
+ return
900
+ }
901
+
902
+ if (rule.mode === 'uniq') {
903
+ const targets = positionalsOf(words, UNIQ_VALUE_OPTIONS).map((word) => toAbsolute(word, ctx.cwd))
904
+ if (targets.length >= 2) {
905
+ acc.reads.push(...targets.slice(0, -1))
906
+ acc.writes.push(targets[targets.length - 1])
907
+ } else if (targets.length === 1) {
908
+ acc.reads.push(targets[0])
909
+ }
910
+ return
911
+ }
912
+
913
+ if (rule.mode === 'pager') {
914
+ const logFile = optionValue(words, ['-o', '--log-file'])
915
+ if (logFile === undefined) {
916
+ // 分页器可在交互中执行命令/写文件(`!`、`s` 等),没有日志文件时无法静态判定
917
+ flag(`分页器 ${cmd} 可交互逃逸,无法静态判定`, words[0])
918
+ return
919
+ }
920
+ acc.writes.push(toAbsolute(logFile, ctx.cwd))
921
+ acc.reads.push(...positionalsOf(words, PAGER_VALUE_OPTIONS).map((word) => toAbsolute(word, ctx.cwd)))
922
+ return
923
+ }
924
+
925
+ const valueOptions = new Set(rule.valueOptions ?? [])
926
+ const targetDir = rule.mode === 'last' ? optionValue(words, ['-t', '--target-directory']) : undefined
927
+ const positionals = positionalsOf(words, valueOptions)
928
+ if (rule.mode === 'last') {
929
+ if (targetDir !== undefined) {
930
+ acc.reads.push(...positionals.map((word) => toAbsolute(word, ctx.cwd)))
931
+ acc.writes.push(toAbsolute(targetDir, ctx.cwd))
932
+ return
933
+ }
934
+ if (positionals.length === 0) return
935
+ const last = positionals[positionals.length - 1]
936
+ const leading = rule.leadingReads === true ? positionals.slice(0, -1) : []
937
+ acc.reads.push(...leading.map((word) => toAbsolute(word, ctx.cwd)))
938
+ acc.writes.push(toAbsolute(last, ctx.cwd))
939
+ return
940
+ }
941
+ const targets = positionals.slice(rule.skip ?? 0)
942
+ acc.writes.push(...targets.map((word) => toAbsolute(word, ctx.cwd)))
943
+ }
944
+
945
+ // ─────────────────────────── PowerShell 语义层 ───────────────────────────
946
+
947
+ /** PowerShell 的路径参数(`-Path val` / `-Path:val` 两种形态)。 */
948
+ const PWSH_PATH_PARAMS = ['-path', '-literalpath', '-destination', '-filepath', '-outfile', '-target', '-source', '-childpath']
949
+
950
+ /** 写 cmdlet(含常用别名)→ 提取模式:path(路径参数)/ all(全部目标)/ last(末位是目标)。 */
951
+ const PWSH_WRITE_CMDLETS = new Map([
952
+ ['set-content', 'path'], ['sc', 'path'],
953
+ ['add-content', 'path'], ['ac', 'path'],
954
+ ['out-file', 'path'],
955
+ ['new-item', 'path'], ['ni', 'path'], ['mkdir', 'path'], ['md', 'path'],
956
+ ['remove-item', 'path'], ['rm', 'path'], ['del', 'path'], ['erase', 'path'], ['rd', 'path'], ['rmdir', 'path'],
957
+ ['clear-content', 'path'],
958
+ ['set-item', 'path'], ['si', 'path'],
959
+ ['set-itemproperty', 'path'],
960
+ ['move-item', 'all'], ['mv', 'all'], ['move', 'all'],
961
+ ['rename-item', 'all'], ['ren', 'all'],
962
+ ['copy-item', 'last'], ['cp', 'last'], ['copy', 'last'], ['cpi', 'last'],
963
+ ])
964
+
965
+ /** 只读 cmdlet(含常用别名)。 */
966
+ const PWSH_READ_CMDLETS = new Set([
967
+ 'get-childitem', 'gci', 'ls', 'dir',
968
+ 'get-content', 'gc', 'cat', 'type',
969
+ 'select-string', 'sls',
970
+ 'test-path', 'get-item', 'gi', 'get-itemproperty', 'gp',
971
+ 'measure-object', 'write-output', 'write', 'echo', 'write-host',
972
+ 'resolve-path', 'get-location', 'pwd', 'get-command', 'gcm',
973
+ 'where-object', 'where', 'select-object', 'select', 'sort-object', 'sort',
974
+ 'format-table', 'ft', 'format-list', 'fl', 'out-string', 'get-filehash', 'get-acl',
975
+ 'get-process', 'get-service', 'test-connection', 'compare-object', 'clear-host', 'cls', 'clc',
976
+ ])
977
+
978
+ /** 明确不可静态判定的 cmdlet / 程序(会再起进程、或文本不可见)。 */
979
+ const PWSH_OPAQUE = new Set([
980
+ 'invoke-expression', 'iex', 'start-process', 'saps', 'start',
981
+ 'invoke-command', 'icm', 'invoke-webrequest', 'iwr', 'curl', 'wget',
982
+ 'invoke-restmethod', 'irm', 'set-executionpolicy', 'register-scheduledtask',
983
+ 'new-scheduledtask', 'pwsh', 'powershell', 'cmd', 'wsl', 'bash', 'sh',
984
+ ])
985
+
986
+ /** 拆 PowerShell 参数:路径参数成对取值,其余 `-开关` 不消费下一个词(保守,宁可多算一个项目内目标)。 */
987
+ function pwshArgs(words) {
988
+ const named = new Map()
989
+ const positionals = []
990
+ for (let i = 1; i < words.length; i += 1) {
991
+ const word = words[i]
992
+ if (word.startsWith('-')) {
993
+ const colon = word.indexOf(':')
994
+ const key = (colon === -1 ? word : word.slice(0, colon)).toLowerCase()
995
+ if (!PWSH_PATH_PARAMS.includes(key)) continue
996
+ if (colon !== -1) {
997
+ named.set(key, word.slice(colon + 1))
998
+ continue
999
+ }
1000
+ const value = words[i + 1]
1001
+ if (value !== undefined && !value.startsWith('-')) {
1002
+ named.set(key, value)
1003
+ i += 1
1004
+ }
1005
+ continue
1006
+ }
1007
+ positionals.push(word)
1008
+ }
1009
+ const namedPaths = []
1010
+ for (const key of PWSH_PATH_PARAMS) {
1011
+ const value = named.get(key)
1012
+ if (value !== undefined && value.length > 0) namedPaths.push(value)
1013
+ }
1014
+ return { namedPaths, positionals }
1015
+ }
1016
+
1017
+ /** 分析 pwsh 命令的段列表;与 bash 侧同构。 */
1018
+ function analyzePwsh(segments, ctx) {
1019
+ const acc = { reads: [], writes: [], unanalyzable: null }
1020
+ for (const seg of segments) {
1021
+ analyzePwshSegment(seg, ctx, acc)
1022
+ if (acc.unanalyzable !== null) break
1023
+ }
1024
+ const reads = [...new Set(acc.reads)]
1025
+ const writes = [...new Set(acc.writes)]
1026
+ return { unanalyzable: acc.unanalyzable, reads, writes, readOnly: acc.unanalyzable === null && writes.length === 0 }
1027
+ }
1028
+
1029
+ /** 分析一个 pwsh 段;结果累加到 acc。 */
1030
+ function analyzePwshSegment(seg, ctx, acc) {
1031
+ const flag = (reason, fragment) => {
1032
+ if (acc.unanalyzable === null) acc.unanalyzable = { reason, fragment }
1033
+ }
1034
+ for (const sub of seg.substitutions) {
1035
+ const nested = analyzeCommand(sub, { shell: 'pwsh', cwd: ctx.cwd, depth: ctx.depth + 1 })
1036
+ if (nested.unanalyzable !== null) {
1037
+ flag(`命令替换无法判定:${nested.unanalyzable.reason}`, sub)
1038
+ return
1039
+ }
1040
+ acc.reads.push(...nested.reads)
1041
+ acc.writes.push(...nested.writes)
1042
+ }
1043
+ if (seg.expansion === true) {
1044
+ // 与 bash 侧同口径:单引号/转义里的字面 `$` 不触发未展开判定
1045
+ const fragment = [...seg.words, ...seg.redirects, ...seg.inputs].find((word) => word.includes('$')) ?? '$'
1046
+ flag(`命令含未展开的变量或替换:${fragment}`, fragment)
1047
+ return
1048
+ }
1049
+ for (const input of seg.inputs) acc.reads.push(toAbsolute(input, ctx.cwd))
1050
+ for (const redirect of seg.redirects) acc.writes.push(toAbsolute(redirect, ctx.cwd))
1051
+ if (seg.words.length === 0) return
1052
+ const cmd = seg.words[0].toLowerCase().replace(/\.exe$/, '')
1053
+ if (PWSH_OPAQUE.has(cmd)) {
1054
+ flag(`无法静态判定:${cmd}`, seg.words[0])
1055
+ return
1056
+ }
1057
+ const { namedPaths, positionals } = pwshArgs(seg.words)
1058
+ const targets = namedPaths.length > 0 ? namedPaths : positionals
1059
+ const writeMode = PWSH_WRITE_CMDLETS.get(cmd)
1060
+ if (writeMode !== undefined) {
1061
+ if (writeMode === 'last' && targets.length > 0) {
1062
+ acc.reads.push(...targets.slice(0, -1).map((word) => toAbsolute(word, ctx.cwd)))
1063
+ acc.writes.push(toAbsolute(targets[targets.length - 1], ctx.cwd))
1064
+ return
1065
+ }
1066
+ if (writeMode === 'last') return
1067
+ acc.writes.push(...targets.map((word) => toAbsolute(word, ctx.cwd)))
1068
+ return
1069
+ }
1070
+ if (PWSH_READ_CMDLETS.has(cmd)) {
1071
+ acc.reads.push(...targets.map((word) => toAbsolute(word, ctx.cwd)))
1072
+ return
1073
+ }
1074
+ // 未知命令:只有"该段本身在写重定向、且首词像裸值/表达式"时才算可判定(如 `"x" > out.txt`)
1075
+ const looksLikeValue = !cmd.includes('/') && !cmd.includes('\\') && !cmd.includes('.')
1076
+ if (looksLikeValue && seg.redirects.length > 0) return
1077
+ flag(`未收录的 cmdlet/程序(无法判定读写):${cmd}`, seg.words[0])
1078
+ }