@taliesinb/mash 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.
Files changed (203) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +275 -2
  3. package/bin/mash.ts +253 -0
  4. package/dist/bin/mash.d.ts +39 -0
  5. package/dist/bin/mash.d.ts.map +1 -0
  6. package/dist/bin/mash.js +281 -0
  7. package/dist/bin/mash.js.map +1 -0
  8. package/dist/src/analysis/analyze.d.ts +97 -0
  9. package/dist/src/analysis/analyze.d.ts.map +1 -0
  10. package/dist/src/analysis/analyze.js +1406 -0
  11. package/dist/src/analysis/analyze.js.map +1 -0
  12. package/dist/src/analysis/fs.d.ts +39 -0
  13. package/dist/src/analysis/fs.d.ts.map +1 -0
  14. package/dist/src/analysis/fs.js +117 -0
  15. package/dist/src/analysis/fs.js.map +1 -0
  16. package/dist/src/analysis/outputs.d.ts +16 -0
  17. package/dist/src/analysis/outputs.d.ts.map +1 -0
  18. package/dist/src/analysis/outputs.js +277 -0
  19. package/dist/src/analysis/outputs.js.map +1 -0
  20. package/dist/src/analysis/value.d.ts +76 -0
  21. package/dist/src/analysis/value.d.ts.map +1 -0
  22. package/dist/src/analysis/value.js +248 -0
  23. package/dist/src/analysis/value.js.map +1 -0
  24. package/dist/src/behaviors/defs/core.d.ts +15 -0
  25. package/dist/src/behaviors/defs/core.d.ts.map +1 -0
  26. package/dist/src/behaviors/defs/core.js +1084 -0
  27. package/dist/src/behaviors/defs/core.js.map +1 -0
  28. package/dist/src/behaviors/defs/devtools.d.ts +9 -0
  29. package/dist/src/behaviors/defs/devtools.d.ts.map +1 -0
  30. package/dist/src/behaviors/defs/devtools.js +432 -0
  31. package/dist/src/behaviors/defs/devtools.js.map +1 -0
  32. package/dist/src/behaviors/defs/macos.d.ts +12 -0
  33. package/dist/src/behaviors/defs/macos.d.ts.map +1 -0
  34. package/dist/src/behaviors/defs/macos.js +454 -0
  35. package/dist/src/behaviors/defs/macos.js.map +1 -0
  36. package/dist/src/behaviors/defs/tools.d.ts +9 -0
  37. package/dist/src/behaviors/defs/tools.d.ts.map +1 -0
  38. package/dist/src/behaviors/defs/tools.js +639 -0
  39. package/dist/src/behaviors/defs/tools.js.map +1 -0
  40. package/dist/src/behaviors/dsl.d.ts +95 -0
  41. package/dist/src/behaviors/dsl.d.ts.map +1 -0
  42. package/dist/src/behaviors/dsl.js +267 -0
  43. package/dist/src/behaviors/dsl.js.map +1 -0
  44. package/dist/src/behaviors/index.d.ts +12 -0
  45. package/dist/src/behaviors/index.d.ts.map +1 -0
  46. package/dist/src/behaviors/index.js +18 -0
  47. package/dist/src/behaviors/index.js.map +1 -0
  48. package/dist/src/behaviors/registry.d.ts +45 -0
  49. package/dist/src/behaviors/registry.d.ts.map +1 -0
  50. package/dist/src/behaviors/registry.js +266 -0
  51. package/dist/src/behaviors/registry.js.map +1 -0
  52. package/dist/src/behaviors/roles.d.ts +29 -0
  53. package/dist/src/behaviors/roles.d.ts.map +1 -0
  54. package/dist/src/behaviors/roles.js +198 -0
  55. package/dist/src/behaviors/roles.js.map +1 -0
  56. package/dist/src/behaviors/types.d.ts +120 -0
  57. package/dist/src/behaviors/types.d.ts.map +1 -0
  58. package/dist/src/behaviors/types.js +12 -0
  59. package/dist/src/behaviors/types.js.map +1 -0
  60. package/dist/src/index.d.ts +27 -0
  61. package/dist/src/index.d.ts.map +1 -0
  62. package/dist/src/index.js +24 -0
  63. package/dist/src/index.js.map +1 -0
  64. package/dist/src/integrations/deepsea.d.ts +121 -0
  65. package/dist/src/integrations/deepsea.d.ts.map +1 -0
  66. package/dist/src/integrations/deepsea.js +199 -0
  67. package/dist/src/integrations/deepsea.js.map +1 -0
  68. package/dist/src/machine.d.ts +139 -0
  69. package/dist/src/machine.d.ts.map +1 -0
  70. package/dist/src/machine.js +400 -0
  71. package/dist/src/machine.js.map +1 -0
  72. package/dist/src/parser/ast.d.ts +369 -0
  73. package/dist/src/parser/ast.d.ts.map +1 -0
  74. package/dist/src/parser/ast.js +17 -0
  75. package/dist/src/parser/ast.js.map +1 -0
  76. package/dist/src/parser/parser.d.ts +48 -0
  77. package/dist/src/parser/parser.d.ts.map +1 -0
  78. package/dist/src/parser/parser.js +1950 -0
  79. package/dist/src/parser/parser.js.map +1 -0
  80. package/dist/src/policy/effects.d.ts +86 -0
  81. package/dist/src/policy/effects.d.ts.map +1 -0
  82. package/dist/src/policy/effects.js +53 -0
  83. package/dist/src/policy/effects.js.map +1 -0
  84. package/dist/src/policy/policy.d.ts +130 -0
  85. package/dist/src/policy/policy.d.ts.map +1 -0
  86. package/dist/src/policy/policy.js +532 -0
  87. package/dist/src/policy/policy.js.map +1 -0
  88. package/dist/src/printer/doc.d.ts +151 -0
  89. package/dist/src/printer/doc.d.ts.map +1 -0
  90. package/dist/src/printer/doc.js +409 -0
  91. package/dist/src/printer/doc.js.map +1 -0
  92. package/dist/src/printer/highlight.d.ts +53 -0
  93. package/dist/src/printer/highlight.d.ts.map +1 -0
  94. package/dist/src/printer/highlight.js +339 -0
  95. package/dist/src/printer/highlight.js.map +1 -0
  96. package/dist/src/printer/html.d.ts +72 -0
  97. package/dist/src/printer/html.d.ts.map +1 -0
  98. package/dist/src/printer/html.js +423 -0
  99. package/dist/src/printer/html.js.map +1 -0
  100. package/dist/src/printer/print.d.ts +63 -0
  101. package/dist/src/printer/print.d.ts.map +1 -0
  102. package/dist/src/printer/print.js +484 -0
  103. package/dist/src/printer/print.js.map +1 -0
  104. package/dist/src/printer/summary.d.ts +50 -0
  105. package/dist/src/printer/summary.d.ts.map +1 -0
  106. package/dist/src/printer/summary.js +621 -0
  107. package/dist/src/printer/summary.js.map +1 -0
  108. package/dist/src/trace/events.d.ts +183 -0
  109. package/dist/src/trace/events.d.ts.map +1 -0
  110. package/dist/src/trace/events.js +9 -0
  111. package/dist/src/trace/events.js.map +1 -0
  112. package/dist/src/trace/progress.d.ts +58 -0
  113. package/dist/src/trace/progress.d.ts.map +1 -0
  114. package/dist/src/trace/progress.js +83 -0
  115. package/dist/src/trace/progress.js.map +1 -0
  116. package/dist/src/vm/arith.d.ts +29 -0
  117. package/dist/src/vm/arith.d.ts.map +1 -0
  118. package/dist/src/vm/arith.js +258 -0
  119. package/dist/src/vm/arith.js.map +1 -0
  120. package/dist/src/vm/builtins.d.ts +31 -0
  121. package/dist/src/vm/builtins.d.ts.map +1 -0
  122. package/dist/src/vm/builtins.js +1773 -0
  123. package/dist/src/vm/builtins.js.map +1 -0
  124. package/dist/src/vm/emulate.d.ts +75 -0
  125. package/dist/src/vm/emulate.d.ts.map +1 -0
  126. package/dist/src/vm/emulate.js +380 -0
  127. package/dist/src/vm/emulate.js.map +1 -0
  128. package/dist/src/vm/expand.d.ts +68 -0
  129. package/dist/src/vm/expand.d.ts.map +1 -0
  130. package/dist/src/vm/expand.js +744 -0
  131. package/dist/src/vm/expand.js.map +1 -0
  132. package/dist/src/vm/external.d.ts +34 -0
  133. package/dist/src/vm/external.d.ts.map +1 -0
  134. package/dist/src/vm/external.js +228 -0
  135. package/dist/src/vm/external.js.map +1 -0
  136. package/dist/src/vm/fifo.d.ts +66 -0
  137. package/dist/src/vm/fifo.d.ts.map +1 -0
  138. package/dist/src/vm/fifo.js +232 -0
  139. package/dist/src/vm/fifo.js.map +1 -0
  140. package/dist/src/vm/interp.d.ts +209 -0
  141. package/dist/src/vm/interp.d.ts.map +1 -0
  142. package/dist/src/vm/interp.js +1641 -0
  143. package/dist/src/vm/interp.js.map +1 -0
  144. package/dist/src/vm/io.d.ts +180 -0
  145. package/dist/src/vm/io.d.ts.map +1 -0
  146. package/dist/src/vm/io.js +502 -0
  147. package/dist/src/vm/io.js.map +1 -0
  148. package/dist/src/vm/pattern.d.ts +46 -0
  149. package/dist/src/vm/pattern.d.ts.map +1 -0
  150. package/dist/src/vm/pattern.js +498 -0
  151. package/dist/src/vm/pattern.js.map +1 -0
  152. package/dist/src/vm/proc.d.ts +94 -0
  153. package/dist/src/vm/proc.d.ts.map +1 -0
  154. package/dist/src/vm/proc.js +130 -0
  155. package/dist/src/vm/proc.js.map +1 -0
  156. package/dist/src/vm/state.d.ts +104 -0
  157. package/dist/src/vm/state.d.ts.map +1 -0
  158. package/dist/src/vm/state.js +233 -0
  159. package/dist/src/vm/state.js.map +1 -0
  160. package/dist/src/vm/test.d.ts +11 -0
  161. package/dist/src/vm/test.d.ts.map +1 -0
  162. package/dist/src/vm/test.js +277 -0
  163. package/dist/src/vm/test.js.map +1 -0
  164. package/package.json +63 -4
  165. package/src/analysis/analyze.ts +1395 -0
  166. package/src/analysis/fs.ts +121 -0
  167. package/src/analysis/outputs.ts +242 -0
  168. package/src/analysis/value.ts +264 -0
  169. package/src/behaviors/defs/core.ts +970 -0
  170. package/src/behaviors/defs/devtools.ts +400 -0
  171. package/src/behaviors/defs/macos.ts +426 -0
  172. package/src/behaviors/defs/tools.ts +596 -0
  173. package/src/behaviors/dsl.ts +293 -0
  174. package/src/behaviors/index.ts +19 -0
  175. package/src/behaviors/registry.ts +261 -0
  176. package/src/behaviors/roles.ts +187 -0
  177. package/src/behaviors/types.ts +127 -0
  178. package/src/index.ts +27 -0
  179. package/src/integrations/deepsea.ts +259 -0
  180. package/src/machine.ts +483 -0
  181. package/src/parser/ast.ts +472 -0
  182. package/src/parser/parser.ts +1826 -0
  183. package/src/policy/effects.ts +102 -0
  184. package/src/policy/policy.ts +578 -0
  185. package/src/printer/doc.ts +454 -0
  186. package/src/printer/highlight.ts +283 -0
  187. package/src/printer/html.ts +441 -0
  188. package/src/printer/print.ts +528 -0
  189. package/src/printer/summary.ts +601 -0
  190. package/src/trace/events.ts +102 -0
  191. package/src/trace/progress.ts +119 -0
  192. package/src/vm/arith.ts +236 -0
  193. package/src/vm/builtins.ts +1590 -0
  194. package/src/vm/emulate.ts +337 -0
  195. package/src/vm/expand.ts +721 -0
  196. package/src/vm/external.ts +265 -0
  197. package/src/vm/fifo.ts +226 -0
  198. package/src/vm/interp.ts +1623 -0
  199. package/src/vm/io.ts +570 -0
  200. package/src/vm/pattern.ts +477 -0
  201. package/src/vm/proc.ts +147 -0
  202. package/src/vm/state.ts +267 -0
  203. package/src/vm/test.ts +253 -0
@@ -0,0 +1,970 @@
1
+ /**
2
+ * Behavior definitions for common POSIX/GNU utilities and developer tools.
3
+ *
4
+ * Conventions:
5
+ * - Paths are resolved with `h.resolve` (relative to the invocation cwd).
6
+ * - Unknown flags or opaque arguments set `complete: false` rather than
7
+ * guessing; the policy then treats the command's effects as unknown.
8
+ * - `readsStdin` is false when the command has file operands (or never reads stdin).
9
+ */
10
+
11
+ import * as fs from 'node:fs'
12
+ import type { AValue } from '../../analysis/value.ts'
13
+ import { concat, concrete, hole, known, knownPrefix } from '../../analysis/value.ts'
14
+ import type { Effect } from '../../policy/effects.ts'
15
+ import type { ArgSpec, Behavior, BehaviorResult, Invocation } from '../types.ts'
16
+ import { analyzeDef, define, type Def } from '../dsl.ts'
17
+
18
+ const args = (inv: Invocation): AValue[] => inv.argv.slice(1)
19
+
20
+ function incomplete(p: { unknownFlags: string[] }): boolean {
21
+ return p.unknownFlags.length > 0
22
+ }
23
+
24
+ const isStdinOperand = (v: AValue): boolean => concrete(v) === '-'
25
+
26
+ /** A filter that reads its file operands (or stdin when there are none) and writes stdout. */
27
+ function reader(names: string[], spec: ArgSpec, summary: string, more: Def = {}): Behavior {
28
+ return define({ names, summary, spec, operands: 'read*', ...more })
29
+ }
30
+
31
+ /** Commands with no file-system effects that never read stdin (arguments are not inspected). */
32
+ function pure(names: string[], summary: string, stdout?: BehaviorResult['stdout']): Behavior {
33
+ return { names, summary, analyze: () => ({ effects: [], readsStdin: false, stdout }) }
34
+ }
35
+
36
+ const BSD = process.platform === 'darwin'
37
+ const SSH_SPEC: ArgSpec = { short: '46AaCfGgKkMNnqsTtVvXxYy', shortWithArg: 'BbcDEeFIiJLlmOoPpQRSWw', posix: true }
38
+ const NET_CLIENTS: Record<string, ArgSpec> = {
39
+ ssh: SSH_SPEC,
40
+ scp: { short: '346ABCOpqRrTv', shortWithArg: 'cDFiJloPSX' },
41
+ sftp: { short: '46AaCfNpqrv', shortWithArg: 'BbcDFiJloPRSsX' },
42
+ nc: { short: '46DdhklnrStUuvzNCF', shortWithArg: 'GwpsxXIOiTVMmeEq' },
43
+ netcat: { short: '46DdhklnrStUuvzNCF', shortWithArg: 'GwpsxXIOiTVMmeEq' },
44
+ telnet: { short: '78EFKLNacdfrx', shortWithArg: 'elnbSX' },
45
+ ftp: { short: '46AadegimnpRtVv', shortWithArg: 'oPqrsTu' },
46
+ ping: { short: '46aAdDfLnoqQrRvU', shortWithArg: 'bcCgGhiIlmMpPsStTwWzk' },
47
+ ping6: { short: 'aAdDfHmnNoqtvwyY', shortWithArg: 'bcgGhiIlmpPsSzk' },
48
+ traceroute: { short: '46adeFIlnrSvxEP', shortWithArg: 'AfgimMpPqsStwz' },
49
+ traceroute6: { short: 'adIlnrSvU', shortWithArg: 'fgimpqstw' },
50
+ dig: { short: '46hiruvm', shortWithArg: 'bcfkpqtxy' },
51
+ host: { short: 'aACdilrsTUvVw46', shortWithArg: 'cNRtWm' },
52
+ nslookup: { longSingle: true, long: { debug: false, d2: false, nodebug: false } },
53
+ }
54
+ const CHMOD: Def = {
55
+ spec: { short: 'RfvchHLPN', long: { recursive: '=R', reference: true, verbose: false, changes: false, silent: false, quiet: false, 'no-dereference': false, dereference: false, 'preserve-root': false, 'no-preserve-root': false, from: true } },
56
+ operands: 'arg write*', recursive: ['R'], stdin: false,
57
+ when: { reference: { operands: 'write*', flags: { reference: 'read' } } },
58
+ }
59
+ const HEAD_TAIL: ArgSpec = { short: 'qvzfFr', shortWithArg: 'ncsb', numeric: 'n', long: { lines: '=n', bytes: '=c', follow: false, quiet: false, silent: false, verbose: false, retry: false, pid: true, 'sleep-interval': '=s', 'zero-terminated': false } }
60
+
61
+ export const coreBehaviors: Behavior[] = [
62
+ pure(['true', 'false', ':'], 'no effect'),
63
+ pure(['echo', 'printf'], 'write arguments to stdout'),
64
+ pure(['sleep'], 'wait'),
65
+ pure(['seq'], 'print a sequence', { type: 'int', lines: true }),
66
+ pure(['date'], 'print the date'),
67
+ pure(['whoami', 'id', 'groups', 'logname', 'who', 'w', 'users', 'last'], 'print user identity'),
68
+ pure(['uname', 'hostname', 'arch', 'nproc', 'getconf', 'sw_vers', 'tty', 'locale', 'uptime', 'vm_stat', 'iostat', 'free'], 'print system information'),
69
+ pure(['ps', 'top', 'pstree', 'lsof', 'netstat'], 'list processes and open files'),
70
+ pure(['printenv'], 'print environment'),
71
+ pure(['expr', 'factor'], 'evaluate an expression'),
72
+ pure(['yes'], 'repeat a string'),
73
+ pure(['uuidgen'], 'print a UUID', { type: 'string' }),
74
+ pure(['clear', 'tput', 'stty', 'reset'], 'terminal control'),
75
+ pure(['which', 'type', 'command', 'whereis'], 'locate a command', { type: 'path' }),
76
+ {
77
+ names: ['basename'],
78
+ summary: 'strip directory and suffix',
79
+ analyze: () => ({ effects: [], readsStdin: false, stdout: { type: 'filename' } }),
80
+ },
81
+ {
82
+ names: ['dirname'],
83
+ summary: 'strip last component',
84
+ analyze: () => ({ effects: [], readsStdin: false, stdout: { type: 'path' } }),
85
+ },
86
+ {
87
+ names: ['pwd'],
88
+ summary: 'print working directory',
89
+ analyze: (inv) => ({ effects: [], readsStdin: false, stdout: { type: 'path', value: inv.cwd } }),
90
+ },
91
+ define({
92
+ names: ['realpath', 'readlink'], summary: 'resolve paths',
93
+ spec: { short: 'esmqzfnLP', long: { canonicalize: false, 'canonicalize-existing': false, 'canonicalize-missing': false, 'no-newline': false, relative: true, 'relative-to': true, 'relative-base': true, quiet: false, zero: false } },
94
+ operands: 'read*', stdin: false, stdout: { type: 'path' },
95
+ }),
96
+ define({
97
+ names: ['pgrep'], summary: 'find processes by name',
98
+ spec: { short: 'flvxinoaciLAIwr', shortWithArg: 'dgGPsuUtFqS', long: { full: false, 'list-name': false, 'list-full': false, exact: false, 'ignore-case': false, newest: false, oldest: false, count: false, inverse: false, delimiter: '=d', parent: '=P', uid: '=u', euid: '=U', terminal: '=t', pidfile: '=F', signal: true } },
99
+ operands: 'pattern*', flags: { F: 'read' }, stdin: false,
100
+ }),
101
+ define({
102
+ names: ['bc'], summary: 'calculator (reads its files, then stdin)',
103
+ spec: { short: 'hilqsvwcCLP', shortWithArg: 'e', long: { mathlib: false, quiet: false, standard: false, warn: false, version: false, help: false, interactive: false, expression: '=e' } },
104
+ operands: 'read*', stdin: true, flags: { e: 'code' },
105
+ }),
106
+ pure(['envsubst'], 'substitute environment variables (stdin to stdout)'),
107
+
108
+ reader(['cat', 'tac', 'nl', 'rev', 'fold', 'fmt', 'strings', 'hexdump', 'more', 'less', 'bat', 'expand', 'unexpand'],
109
+ { short: 'AbeEnstTuvrwqpSlc', shortWithArg: 'wnN', long: { number: false, 'show-all': false, squeeze: false, 'number-nonblank': false, 'show-ends': false, 'show-tabs': false, plain: false, paging: true, language: true, style: true, 'line-range': true, color: true, width: true, tabs: true, all: false, initial: false } }, 'read files, write stdout'),
110
+ reader(['od'], { short: 'bcdDfFhHiIlLoOsvxX', shortWithArg: 'AjNtwS', long: { 'address-radix': '=A', 'skip-bytes': '=j', 'read-bytes': '=N', format: '=t', 'output-duplicates': false, width: true, endian: true } }, 'dump files'),
111
+ // xxd INFILE [OUTFILE]; -r reverses (still writes OUTFILE)
112
+ define({ names: ['xxd'], summary: 'hex dump (xxd [in [out]])', spec: { short: 'abCeEhiprRuv', shortWithArg: 'cglosn', long: { autoskip: false, bits: false, include: false, plain: false, revert: false, uppercase: false, cols: '=c', len: '=l', seek: '=s', name: '=n' } }, operands: 'read write?' }),
113
+ reader(['column'], { short: 'tJLnxerRlH', shortWithArg: 'sOcoNWdET', long: { table: false, separator: '=s', 'output-separator': '=o', 'output-width': '=c', 'table-columns': '=N', json: false } }, 'format columns'),
114
+ // GNU base64 [-d] [-w N] [FILE]; BSD base64 [-dDh] [-b N] [-i IN] [-o OUT]
115
+ reader(['base64', 'base32', 'basenc'], { short: 'dDhn', shortWithArg: 'wbio', long: { decode: false, 'ignore-garbage': false, wrap: '=w', input: '=i', output: '=o', break: '=b', base64: false, base64url: false, base32: false, base32hex: false, base16: false } },
116
+ 'encode / decode', { flags: { i: 'read', o: 'write' }, when: { i: { stdin: false } } }),
117
+ reader(['head', 'tail'], HEAD_TAIL, 'print part of files'),
118
+ reader(['wc'], { short: 'clmwL', long: { bytes: false, chars: false, lines: false, words: false, 'max-line-length': false } }, 'count', { stdout: { type: 'string' } }),
119
+ reader(['cut'], { short: 'snzw', shortWithArg: 'bcdf', long: { bytes: '=b', characters: '=c', delimiter: '=d', fields: '=f', complement: false, 'only-delimited': false, 'output-delimiter': true, 'zero-terminated': false } }, 'cut fields'),
120
+ reader(['paste'], { short: 'sz', shortWithArg: 'd', long: { delimiters: '=d', serial: false } }, 'merge lines'),
121
+ reader(['shuf'], { short: 'erzn', shortWithArg: 'inoS', long: { echo: false, 'input-range': '=i', 'head-count': '=n', output: '=o', 'random-source': true, repeat: false } }, 'shuffle lines',
122
+ { flags: { o: 'write', 'random-source': 'read' }, when: { e: { operands: 'arg*', stdin: false }, i: { operands: '', stdin: false } } }),
123
+ reader(['iconv'], { short: 'csl', shortWithArg: 'fto', long: { 'from-code': '=f', 'to-code': '=t', output: '=o', list: false, silent: false } }, 'convert encodings', { flags: { o: 'write' } }),
124
+ reader(['join'], { short: 'iz', shortWithArg: 'ajoetv12', long: { 'ignore-case': false, 'check-order': false, 'nocheck-order': false, header: false } }, 'join sorted files'),
125
+ define({
126
+ names: ['uniq'], summary: 'filter repeated lines (uniq [IN [OUT]])',
127
+ spec: { short: 'cdDiuz', shortWithArg: 'fsw', long: { count: false, repeated: false, unique: false, 'ignore-case': false, 'skip-fields': '=f', 'skip-chars': '=s', 'check-chars': '=w', 'all-repeated': true, group: true, 'zero-terminated': false } },
128
+ operands: 'read? write?',
129
+ // OUT of `-` is stdout
130
+ extra: (r, { operands }) => (operands[1] && isStdinOperand(operands[1]) ? { ...r, effects: r.effects.filter((e) => e.kind !== 'write') } : r),
131
+ }),
132
+ {
133
+ names: ['csplit'],
134
+ summary: 'split a file at patterns into pieces named PREFIXnn',
135
+ analyze(inv, h) {
136
+ const p = h.parseArgs(args(inv), { short: 'ksqz', shortWithArg: 'fbn', long: { prefix: '=f', 'suffix-format': '=b', digits: '=n', 'keep-files': '=k', quiet: '=q', silent: '=s', 'elide-empty-files': '=z', 'suppress-matched': false } })
137
+ const [input, ...patterns] = p.operands
138
+ for (const pat of patterns) h.role(pat, 'pattern')
139
+ const effects: Effect[] = []
140
+ if (input && !isStdinOperand(input)) effects.push({ kind: 'read', path: h.resolve(input) })
141
+ const f = p.flags.get('f')
142
+ const target = h.resolve(f && f !== true ? f : known('xx'))
143
+ effects.push({ kind: 'write', path: { alts: target.alts.map((a) => [...a, { hole: true, type: 'filename', origin: 'csplit suffix' }]) } })
144
+ return { effects, readsStdin: !input || isStdinOperand(input), complete: !incomplete(p) }
145
+ },
146
+ },
147
+ {
148
+ names: ['split'],
149
+ summary: 'split a file into pieces named PREFIX*',
150
+ analyze(inv, h) {
151
+ const p = h.parseArgs(args(inv), { short: 'dxe', shortWithArg: 'lbnCa', long: { lines: true, bytes: true, number: true, 'line-bytes': true, 'suffix-length': true, 'numeric-suffixes': false, 'additional-suffix': true, verbose: false } })
152
+ const [input, prefix] = p.operands
153
+ const effects: Effect[] = []
154
+ if (input && !isStdinOperand(input)) effects.push({ kind: 'read', path: h.resolve(input) })
155
+ const pre = prefix ?? known('x')
156
+ const target = h.resolve(pre)
157
+ effects.push({ kind: 'write', path: { alts: target.alts.map((a) => [...a, { hole: true, type: 'filename', origin: 'split suffix' }]) } })
158
+ return { effects, readsStdin: !input || isStdinOperand(input), complete: !incomplete(p) }
159
+ },
160
+ },
161
+ reader(['comm'], { short: '123z', long: { 'check-order': false, 'nocheck-order': false, 'output-delimiter': true, total: false } }, 'compare sorted files'),
162
+ reader(['diff', 'cmp'], { short: 'uqrNbBiwasylcntTpdEZ', shortWithArg: 'UCxXIFS', long: { unified: true, recursive: false, brief: false, color: true, 'ignore-all-space': false, 'ignore-space-change': false, 'ignore-blank-lines': false, 'ignore-case': false, exclude: true, 'exclude-from': '=X', text: false, 'new-file': false, 'side-by-side': false, 'strip-trailing-cr': false, label: true, 'no-dereference': false, normal: false, minimal: false, silent: false, quiet: false, 'ignore-matching-lines': '=I', 'show-function-line': '=F', 'starting-file': '=S', 'width': true, 'suppress-common-lines': false } },
163
+ 'compare files', { flags: { X: 'read' }, recursive: ['r', 'recursive'] }),
164
+ reader(['md5sum', 'sha1sum', 'sha224sum', 'sha256sum', 'sha384sum', 'sha512sum', 'shasum', 'cksum', 'b2sum', 'b3sum', 'sum'], { short: 'bctwzU0sq', shortWithArg: 'aol', long: { check: false, binary: false, text: false, algorithm: '=a', tag: false, quiet: false, status: false, strict: false, warn: false, zero: false, length: '=l', untagged: false, 'ignore-missing': false, raw: false, base64: false } }, 'checksum files'),
165
+ // BSD md5: -s STRING checksums a string
166
+ reader(['md5'], { short: 'pqrtxn', shortWithArg: 's' }, 'checksum files', { when: { s: { stdin: false } } }),
167
+ reader(['file'], { short: 'bcdhiklLNnprsvzZ0SI', shortWithArg: 'fmFeP', long: { brief: false, 'mime-type': false, 'mime-encoding': false, mime: false, dereference: false, 'no-dereference': false, 'files-from': '=f', 'magic-file': '=m', separator: '=F', exclude: '=e', 'keep-going': false, raw: false, special: false, uncompress: false } },
168
+ 'inspect files', { flags: { f: 'read', m: 'read' }, stdin: false }),
169
+ // BSD stat: -f FORMAT, -t TIMEFMT; GNU stat: -c FORMAT, -f = file-system status
170
+ reader(['stat'], BSD
171
+ ? { short: 'FLlnqrsx', shortWithArg: 'ftc', long: { format: '=c', dereference: false } }
172
+ : { short: 'Lfte', shortWithArg: 'c', long: { format: '=c', printf: true, dereference: false, 'file-system': false, terse: false, cached: true } },
173
+ 'inspect files', { stdin: false }),
174
+ reader(['tr'], { short: 'cdsCt', long: { complement: false, delete: false, 'squeeze-repeats': false, truncate: false } }, 'translate characters', { operands: 'arg*', stdin: true }),
175
+ define({
176
+ names: ['jq', 'gojq', 'jaq'], summary: 'JSON processor',
177
+ spec: { short: 'rjcnsSeaCMRxh', shortWithArg: 'fL', long: { arg: 2, argjson: 2, slurpfile: 2, rawfile: 2, args: false, jsonargs: false, 'raw-output': false, 'raw-output0': false, 'join-output': false, 'ascii-output': false, compact: false, 'null-input': false, 'raw-input': false, slurp: false, tab: false, indent: true, 'sort-keys': false, 'color-output': false, 'monochrome-output': false, 'exit-status': false, 'from-file': '=f', seq: false, stream: false, 'stream-errors': false } },
178
+ operands: 'code read*', flags: { f: 'read', slurpfile: 'read', rawfile: 'read' },
179
+ when: { f: { operands: 'read*' }, 'args|jsonargs': { operands: 'code arg*', stdin: true }, n: { stdin: false } },
180
+ }),
181
+ // grep needs special handling: the first operand is the pattern unless -e/-f
182
+ {
183
+ names: ['grep', 'egrep', 'fgrep', 'rg', 'ag'],
184
+ summary: 'search files',
185
+ analyze(inv, h) {
186
+ const rg = inv.name === 'rg' || inv.name === 'ag'
187
+ const p = h.parseArgs(args(inv), {
188
+ short: rg ? 'iFwxclLnNHosqvuUzSa.P' : 'EFGPivwxyclLnhHosqbrRUzZaIT',
189
+ shortWithArg: rg ? 'efgtTmABCjMrE' : 'efmABCdD',
190
+ long: { regexp: true, file: true, 'ignore-case': false, 'invert-match': false, 'word-regexp': false, 'line-regexp': false, count: false, 'files-with-matches': false, 'files-without-match': false, 'line-number': false, 'no-filename': false, 'with-filename': false, 'only-matching': false, quiet: false, silent: false, recursive: false, 'dereference-recursive': false, include: true, exclude: true, 'exclude-dir': true, 'exclude-from': true, color: true, colour: true, 'max-count': true, context: true, 'after-context': true, 'before-context': true, 'extended-regexp': false, 'fixed-strings': false, 'perl-regexp': false, 'null-data': false, binary: false, text: false, glob: true, iglob: true, type: true, 'type-not': true, 'type-add': true, hidden: false, 'no-ignore': false, 'no-ignore-vcs': false, json: false, 'files': false, 'smart-case': false, 'max-depth': true, 'no-heading': false, heading: false, 'no-messages': false, 'line-buffered': false, multiline: false, 'multiline-dotall': false, replace: true, 'sort': true, 'sortr': true, 'follow': false, 'max-columns': true, 'max-filesize': true, threads: true, 'column': false, 'vimgrep': false, 'stats': false, 'pcre2': false, 'null': false, 'byte-offset': false, 'trim': false, 'passthru': false, 'unrestricted': false, 'no-config': false, 'count-matches': false, 'ignore-file': true, 'label': true, 'initial-tab': false, 'devices': true, 'directories': true, 'binary-files': true } },
191
+ )
192
+ const operands = [...p.operands]
193
+ for (const e of [...(p.multi.get('e') ?? []), ...(p.multi.get('regexp') ?? [])]) h.role(e, 'pattern')
194
+ if (!p.flags.has('e') && !p.flags.has('f') && !p.flags.has('regexp') && !p.flags.has('file') && !(rg && (p.flags.has('files') || p.flags.has('type-list')))) {
195
+ const pat = operands.shift()
196
+ if (pat) h.role(pat, 'pattern')
197
+ }
198
+ const recursive = rg || p.flags.has('r') || p.flags.has('R') || p.flags.has('recursive') || p.flags.has('dereference-recursive')
199
+ const files = operands.filter((f) => !isStdinOperand(f))
200
+ const effects: Effect[] = files.map((f) => ({ kind: 'read', path: h.resolve(f), recursive }))
201
+ // rg and grep -r search the cwd without operands
202
+ if (operands.length === 0 && recursive) effects.push({ kind: 'read', path: inv.cwd, recursive: true })
203
+ const pf = p.flags.get('f') ?? p.flags.get('file')
204
+ if (pf && pf !== true && !isStdinOperand(pf)) effects.push({ kind: 'read', path: h.resolve(pf) })
205
+ const readsStdin = !rg && !recursive ? operands.length === 0 || operands.some(isStdinOperand) : false
206
+ return { effects, readsStdin, complete: !incomplete(p) }
207
+ },
208
+ },
209
+ define({
210
+ names: ['sort'], summary: 'sort lines',
211
+ spec: { short: 'bdfgiMhnRrscCumVz', shortWithArg: 'kotST', long: { output: '=o', key: '=k', 'field-separator': '=t', reverse: false, numeric: false, 'numeric-sort': false, unique: false, 'human-numeric-sort': false, 'version-sort': false, 'general-numeric-sort': false, 'ignore-case': false, 'month-sort': false, 'random-sort': false, 'ignore-leading-blanks': false, 'dictionary-order': false, check: false, merge: false, parallel: true, 'buffer-size': '=S', 'temporary-directory': '=T', stable: false, 'zero-terminated': false, 'files0-from': true, 'random-source': true, debug: false } },
212
+ operands: 'read*', flags: { o: 'write', T: 'write:dir~', 'files0-from': 'read', 'random-source': 'read' },
213
+ }),
214
+ define({
215
+ names: ['tee'], summary: 'copy stdin to files',
216
+ spec: { short: 'aip', long: { append: '=a', 'ignore-interrupts': '=i', 'output-error': true } },
217
+ operands: 'write*', stdin: true, when: { a: { operands: 'append*' } },
218
+ }),
219
+ define({
220
+ names: ['ls'], summary: 'list directories', namesOnly: true,
221
+ spec: { short: '1aAbBcCdfFgGhHiklLmnNopqQrRsStTuUvwxXZ@eO', shortWithArg: BSD ? 'IDP' : 'IP', long: { all: false, 'almost-all': false, color: true, classify: false, human: false, 'human-readable': false, recursive: false, directory: false, 'block-size': true, 'group-directories-first': false, time: true, 'time-style': true, 'full-time': false, sort: true, ignore: true, 'hide': true, si: false, inode: false, 'dereference': false, 'quoting-style': true, literal: false, width: true, format: true, 'indicator-style': true, 'hyperlink': true } },
222
+ operands: 'read*', cwdIfNone: true, recursive: ['R', 'recursive'], stdin: false,
223
+ // operands are usually directories (a name with an extension is more likely a file)
224
+ extra: (r, { h, operands }) => {
225
+ for (const f of operands) if (!/\.[A-Za-z]\w{0,7}$/.test(concrete(f) ?? '')) h.role(f, 'dir')
226
+ return r
227
+ },
228
+ }),
229
+ define({
230
+ names: ['tree', 'du'], summary: 'list directory trees', namesOnly: true,
231
+ spec: { short: '1aAbBcCdfFgGhHiklLmnNopqQrRsStTuUvwxXZ@eDJ', shortWithArg: 'IdLPt', long: { all: false, 'max-depth': '=d', summarize: false, total: false, 'block-size': true, human: false, 'human-readable': false, si: false, apparent: false, 'apparent-size': false, exclude: true, 'one-file-system': false, 'dirsfirst': false, 'noreport': false, charset: true, 'gitignore': false, threshold: '=t', 'time': true, inodes: false, 'null': false, 'count-links': false, 'dereference': false, 'exclude-from': true, 'files0-from': true } },
232
+ operands: 'read*', cwdIfNone: true, recursive: true, stdin: false,
233
+ }),
234
+ pure(['df'], 'report disk usage'),
235
+
236
+ // ---- writers ----
237
+ define({
238
+ names: ['touch'], summary: 'create files / update times',
239
+ spec: { short: 'acmfh', shortWithArg: 'rtdA', long: { 'no-create': false, date: '=d', reference: '=r', time: true, 'no-dereference': false } },
240
+ operands: 'write*', flags: { r: 'read' }, stdin: false,
241
+ // -c: only existing files are touched (at runtime the path is known: check it)
242
+ when: {
243
+ 'c|no-create': {
244
+ operands: 'write~*',
245
+ extra: (r, { inv }) => ({ ...r, effects: r.effects.flatMap((e): Effect[] => {
246
+ if (e.kind !== 'write' || !e.maybe) return [e]
247
+ const p = concrete(e.path)
248
+ // statically, an earlier command of the snippet may create it
249
+ if (p === undefined || inv.mayHaveChanged?.(p)) return [e]
250
+ return fs.existsSync(p) ? [{ kind: 'write', path: e.path }] : []
251
+ }) }),
252
+ },
253
+ },
254
+ }),
255
+ define({ names: ['mkdir'], summary: 'create directories', spec: { short: 'pv', shortWithArg: 'm', long: { parents: false, verbose: false, mode: '=m' } }, operands: 'write:dir*', stdin: false }),
256
+ define({ names: ['mkfifo', 'mknod'], summary: 'create special files', spec: { short: 'Z', shortWithArg: 'm', long: { mode: '=m' } }, operands: 'write arg*', stdin: false }),
257
+ define({ names: ['rmdir'], summary: 'remove empty directories', spec: { short: 'pv', long: { parents: false, 'ignore-fail-on-non-empty': false, verbose: false } }, operands: 'delete:dir*', stdin: false }),
258
+ define({
259
+ names: ['rm', 'unlink', 'shred'], summary: 'remove files',
260
+ spec: { short: 'rRfivdIuxzWP', shortWithArg: 'ns', long: { recursive: false, force: false, interactive: true, dir: false, verbose: false, 'one-file-system': false, 'no-preserve-root': false, 'preserve-root': false, iterations: '=n', size: '=s', remove: false, zero: false, exact: false } },
261
+ operands: 'delete*', recursive: ['r', 'R', 'recursive'], stdin: false,
262
+ extra: (r, { inv, p }) => {
263
+ // shred overwrites in place, and removes only with -u
264
+ if (inv.name === 'shred') return { ...r, effects: r.effects.flatMap((e): Effect[] => (e.kind === 'delete' ? [{ kind: 'write', path: e.path }, ...(p.flags.has('u') || p.flags.has('remove') ? [e] : [])] : [e])) }
265
+ // rm -f: a missing operand is not an error (for the opt-in `missingDeletes: 'allow'`)
266
+ if (inv.name === 'rm' && (p.flags.has('f') || p.flags.has('force'))) return { ...r, effects: r.effects.map((e) => (e.kind === 'delete' ? { ...e, ifExists: true } : e)) }
267
+ return r
268
+ },
269
+ }),
270
+ {
271
+ names: ['cp', 'mv', 'install', 'ln', 'rsync'],
272
+ summary: 'copy / move / link files',
273
+ analyze(inv, h) {
274
+ const p = h.parseArgs(args(inv), {
275
+ short: 'aRrfipnvlsLPHdxubTuUcDzSWeXAEhOJN', shortWithArg: 'tmogSeBM',
276
+ long: { recursive: false, force: false, interactive: false, 'no-clobber': false, verbose: false, archive: false, symbolic: false, 'target-directory': '=t', 'no-target-directory': false, parents: false, preserve: true, 'no-preserve': true, update: false, backup: true, suffix: true, mode: '=m', owner: '=o', group: '=g', delete: false, 'delete-excluded': false, 'delete-after': false, 'delete-before': false, 'delete-during': false, exclude: true, include: true, 'exclude-from': true, 'include-from': true, 'files-from': true, filter: true, 'dry-run': false, progress: false, compress: false, 'remove-source-files': false, rsh: '=e', 'itemize-changes': false, 'ignore-existing': false, partial: false, 'partial-dir': true, stats: false, info: true, 'human-readable': false, checksum: false, times: false, perms: false, links: false, 'copy-links': false, 'hard-links': false, 'no-dereference': false, dereference: false, 'one-file-system': false, quiet: false, 'size-only': false, existing: false, inplace: false, append: false, 'append-verify': false, 'whole-file': false, 'chmod': true, 'chown': true, 'numeric-ids': false, 'mkpath': false, 'strip-trailing-slashes': false, reflink: true, sparse: true, 'copy-contents': false, 'attributes-only': false, 'relative': false, 'no-relative': false, 'safe-links': false, 'max-size': true, 'min-size': true, 'bwlimit': true, 'timeout': true, 'out-format': true, 'log-file': true, 'link-dest': true, 'compare-dest': true, 'copy-dest': true, 'backup-dir': true, 'temp-dir': true, 'no-motd': false, 'list-only': false, 'prune-empty-dirs': false, 'protect-args': false, 'exclude-dir': true } },
277
+ )
278
+ // install -d DIR...: creates every operand
279
+ if (inv.name === 'install' && p.flags.has('d')) {
280
+ for (const d of p.operands) h.role(d, 'dir')
281
+ return { effects: p.operands.map((d): Effect => ({ kind: 'write', path: h.resolve(d) })), readsStdin: false, complete: !incomplete(p) }
282
+ }
283
+ // rsync SRC (no destination) lists SRC
284
+ if (inv.name === 'rsync' && p.operands.length === 1 && !p.flags.has('t')) {
285
+ const src = p.operands[0]
286
+ const effects: Effect[] = [{ kind: 'read', path: h.resolve(src), recursive: true }]
287
+ if (/^[^/]*:/.test(knownPrefix(src))) effects.push({ kind: 'network', detail: 'remote rsync' })
288
+ return { effects, readsStdin: false, complete: !incomplete(p) && !p.flags.has('e') }
289
+ }
290
+ const ops = [...p.operands]
291
+ const t = p.flags.get('t')
292
+ const target = t && t !== true ? t : ops.pop()
293
+ if (!target) return { effects: [], readsStdin: false, complete: !incomplete(p) }
294
+ const recursive = inv.name === 'rsync' || p.flags.has('r') || p.flags.has('R') || p.flags.has('a') || p.flags.has('recursive') || inv.name === 'mv'
295
+ const effects: Effect[] = []
296
+ const dryRun = p.flags.has('dry-run') || (inv.name === 'rsync' && (p.flags.has('n') || p.flags.has('list-only')))
297
+ for (const src of ops) {
298
+ effects.push({ kind: 'read', path: h.resolve(src), recursive })
299
+ if (inv.name === 'mv' || p.flags.has('remove-source-files')) effects.push({ kind: 'delete', path: h.resolve(src), recursive })
300
+ }
301
+ if (!dryRun) effects.push({ kind: 'write', path: h.resolve(target), recursive })
302
+ let complete = !incomplete(p)
303
+ if (inv.name === 'rsync') {
304
+ const remote = (v: AValue): boolean => /^[^/]*:/.test(knownPrefix(v))
305
+ if (ops.some(remote) || remote(target)) effects.push({ kind: 'network', detail: 'remote rsync' })
306
+ for (const k of ['exclude-from', 'include-from', 'files-from']) {
307
+ const v = p.flags.get(k)
308
+ if (v && v !== true && !isStdinOperand(v)) effects.push({ kind: 'read', path: h.resolve(v) })
309
+ }
310
+ const log = p.flags.get('log-file')
311
+ if (log && log !== true) effects.push({ kind: 'write', path: h.resolve(log), append: true })
312
+ // the remote shell runs locally
313
+ if (p.flags.has('e')) complete = false
314
+ }
315
+ return { effects, readsStdin: false, complete }
316
+ },
317
+ },
318
+ {
319
+ names: ['chmod', 'chown', 'chgrp', 'chflags'],
320
+ summary: 'change file metadata',
321
+ analyze(inv, h) {
322
+ // `chmod -x file`: a mode that looks like a flag
323
+ const a = args(inv)
324
+ const m = inv.name === 'chmod' ? a.findIndex((v) => /^-[rwxXstugoa]+$/.test(concrete(v) ?? '') && !/^-[RfvchHLPN]+$/.test(concrete(v)!)) : -1
325
+ return analyzeDef(CHMOD, inv, m === -1 ? a : [known('mode'), ...a.slice(0, m), ...a.slice(m + 1)], h)
326
+ },
327
+ },
328
+ // xattr [-lrsvx] [-p|-w|-d|-c] ...
329
+ define({
330
+ names: ['xattr'], summary: 'extended attributes',
331
+ spec: { short: 'lrsvxhpwdc' }, operands: 'read*', recursive: ['r'], stdin: false,
332
+ when: { p: { operands: 'arg read*' }, d: { operands: 'arg write*' }, c: { operands: 'write*' }, w: { operands: 'arg arg write*' } },
333
+ }),
334
+ define({
335
+ names: ['truncate'], summary: 'shrink or extend files',
336
+ spec: { short: 'co', shortWithArg: 'sr', long: { size: '=s', reference: '=r', 'no-create': false, 'io-blocks': false } },
337
+ operands: 'write*', flags: { r: 'read' }, stdin: false,
338
+ }),
339
+ {
340
+ names: ['patch'],
341
+ summary: 'apply a diff to files',
342
+ analyze(inv, h) {
343
+ const p = h.parseArgs(args(inv), {
344
+ short: 'bcefElnNRstTuvZ',
345
+ shortWithArg: 'pBdDFgioruVYzx',
346
+ long: {
347
+ strip: '=p', directory: '=d', input: '=i', output: '=o', 'reject-file': '=r', 'dry-run': false, forward: false, reverse: false,
348
+ silent: false, quiet: false, force: false, batch: false, unified: false, context: false, normal: false, backup: false,
349
+ 'remove-empty-files': false, binary: false, posix: false, suffix: '=z', prefix: '=B', 'version-control': '=V', fuzz: '=F', merge: true, verbose: false,
350
+ },
351
+ })
352
+ const flag = (k: string): AValue | undefined => {
353
+ const v = p.flags.get(k)
354
+ return v && v !== true ? v : undefined
355
+ }
356
+ const [original, patchfile] = p.operands
357
+ const input = flag('i') ?? patchfile
358
+ const output = flag('o')
359
+ const dir = flag('d')
360
+ const dry = p.flags.has('dry-run')
361
+ const effects: Effect[] = []
362
+ if (input && !isStdinOperand(input)) effects.push({ kind: 'read', path: h.resolve(input) })
363
+ let complete = !incomplete(p)
364
+ if (original) {
365
+ const kind = dry || output ? 'read' : 'write'
366
+ if (dir === undefined) effects.push({ kind, path: h.resolve(original) })
367
+ else {
368
+ // operands are relative to -d DIR
369
+ const d = concrete(h.resolve(dir))
370
+ const o = concrete(original)
371
+ if (d !== undefined && o !== undefined) effects.push({ kind, path: h.resolve(known(o.startsWith('/') ? o : `${d}/${o}`)) })
372
+ else {
373
+ effects.push({ kind, path: h.resolve(dir), recursive: true })
374
+ complete = false
375
+ }
376
+ }
377
+ } else if (!dry && !output) {
378
+ // the files named in the diff, below the working (or -d) directory: GNU patch refuses
379
+ // absolute and `..` names; other implementations may not, so this is incomplete
380
+ effects.push({ kind: 'write', path: h.resolve(dir ?? known('.')), recursive: true })
381
+ complete = false
382
+ }
383
+ if (output && !dry && !isStdinOperand(output)) effects.push({ kind: 'write', path: h.resolve(output) })
384
+ const rej = flag('r')
385
+ if (rej && !dry) effects.push({ kind: 'write', path: h.resolve(rej) })
386
+ return { effects, readsStdin: !input || isStdinOperand(input), complete }
387
+ },
388
+ },
389
+ {
390
+ names: ['mktemp'],
391
+ summary: 'create a temporary file or directory',
392
+ analyze(inv, h) {
393
+ const p = h.parseArgs(args(inv), { short: 'dquturt', shortWithArg: 'p', long: { directory: false, quiet: false, 'dry-run': false, tmpdir: true, suffix: true } })
394
+ const tmp = (concrete(inv.env('TMPDIR') ?? known('/tmp')) ?? '/tmp').replace(/\/+$/, '') || '/tmp'
395
+ const pdir = p.flags.get('p') ?? p.flags.get('tmpdir')
396
+ const template = p.operands[0]
397
+ let under: string | undefined = tmp
398
+ if (pdir && pdir !== true) under = concrete(h.resolve(pdir))
399
+ else if (template) {
400
+ const t = concrete(template)
401
+ if (t === undefined) under = undefined
402
+ else if (t.includes('/')) under = concrete(h.resolve(known(t.slice(0, t.lastIndexOf('/')) || '/')))
403
+ else if (!p.flags.has('t')) under = concrete(inv.cwd)
404
+ }
405
+ const created = hole('path', '$(mktemp)', under ? { under } : {})
406
+ return {
407
+ effects: p.flags.has('u') || p.flags.has('dry-run') ? [] : [{ kind: 'write', path: created }],
408
+ readsStdin: false,
409
+ stdout: { type: 'path', under },
410
+ complete: !incomplete(p),
411
+ }
412
+ },
413
+ },
414
+ {
415
+ names: ['dd'],
416
+ summary: 'convert and copy',
417
+ analyze(inv, h) {
418
+ const effects: Effect[] = []
419
+ let readsStdin = true
420
+ for (const a of args(inv)) {
421
+ const s = knownPrefix(a)
422
+ const rest = (prefix: string): AValue => ({ alts: a.alts.map((alt) => [typeof alt[0] === 'string' ? alt[0].slice(prefix.length) : '', ...alt.slice(1)]) })
423
+ if (s.startsWith('if=')) {
424
+ effects.push({ kind: 'read', path: h.resolve(rest('if=')) })
425
+ readsStdin = false
426
+ } else if (s.startsWith('of=')) effects.push({ kind: 'write', path: h.resolve(rest('of=')) })
427
+ }
428
+ return { effects, readsStdin }
429
+ },
430
+ },
431
+ {
432
+ names: ['sed', 'gsed'],
433
+ summary: 'stream editor',
434
+ analyze(inv, h) {
435
+ // -i: GNU takes an optional attached suffix (`-i.bak`, never the next word);
436
+ // BSD sed takes the next word as the suffix (`-i ''`, `-i .bak`)
437
+ const bsd = BSD && inv.name === 'sed'
438
+ const a: AValue[] = []
439
+ const raw = args(inv)
440
+ for (let k = 0; k < raw.length; k++) {
441
+ const s = concrete(raw[k])
442
+ const m = s === undefined ? null : /^-([nrEsuzl]*)i(.*)$/.exec(s)
443
+ if (!m) {
444
+ a.push(raw[k])
445
+ if (s === '--') a.push(...raw.slice(k + 1))
446
+ if (s === '--') break
447
+ continue
448
+ }
449
+ a.push(known(`-${m[1]}i`))
450
+ if (m[2] === '' && bsd) {
451
+ const next = raw[k + 1] === undefined ? undefined : concrete(raw[k + 1])
452
+ if (next !== undefined && (next === '' || /^[.~_-]/.test(next) && next !== '-')) k++
453
+ }
454
+ }
455
+ const p = h.parseArgs(a, { short: 'nrEsuzli', shortWithArg: 'efl', long: { expression: '=e', file: '=f', 'in-place': false, quiet: false, silent: false, 'regexp-extended': false, separate: false, 'null-data': false, 'zero-terminated': false, posix: false, sandbox: false, debug: false, 'line-length': '=l', 'follow-symlinks': false, unbuffered: false } })
456
+ const ops = [...p.operands]
457
+ const scripts = p.multi.get('e') ?? []
458
+ if (scripts.length === 0 && !p.flags.has('f')) {
459
+ const s = ops.shift()
460
+ if (s) scripts.push(s)
461
+ }
462
+ for (const s of scripts) h.role(s, 'code')
463
+ const inPlace = p.flags.has('i') || p.flags.has('in-place')
464
+ const effects: Effect[] = ops.map((f) => ({ kind: 'read', path: h.resolve(f) }))
465
+ if (inPlace) for (const f of ops) effects.push({ kind: 'write', path: h.resolve(f) })
466
+ const sf = p.flags.get('f')
467
+ if (sf && sf !== true) effects.push({ kind: 'read', path: h.resolve(sf) })
468
+ let complete = !incomplete(p)
469
+ for (const s of scripts) {
470
+ const texts = programTexts(s)
471
+ if (texts === undefined) {
472
+ complete = false
473
+ continue
474
+ }
475
+ for (const text of texts) {
476
+ // `w file` commands and the `e` command
477
+ const w = /(?:^|[;\n}])\s*(?:\d+|\/[^/]*\/|\$)?\s*[wW]\s+(\S+)/.exec(text) ?? /\/[^/]*\/[^/]*\/[gpIiMm0-9]*w\s+(\S+)/.exec(text)
478
+ if (w) effects.push({ kind: 'write', path: h.resolve(known(w[1])) })
479
+ if (/(?:^|[;\n])\s*e(\s|$)/.test(text) || /\/[gpIiMm0-9]*e[gpIiMm0-9]*\s*(?:$|[;}\n])/.test(text)) {
480
+ effects.push({ kind: 'unknown', detail: 'sed e command executes shell commands' })
481
+ complete = false
482
+ }
483
+ }
484
+ }
485
+ return { effects, readsStdin: ops.length === 0, complete }
486
+ },
487
+ },
488
+ {
489
+ names: ['awk', 'gawk', 'mawk', 'nawk'],
490
+ summary: 'pattern scanning language',
491
+ analyze(inv, h) {
492
+ const p = h.parseArgs(args(inv), { short: 'bcCdhlLnNOPrsStV', shortWithArg: 'FvfiE', long: { 'field-separator': '=F', assign: '=v', file: '=f' } })
493
+ const ops = [...p.operands]
494
+ let program: AValue | undefined
495
+ const f = p.flags.get('f')
496
+ const effects: Effect[] = []
497
+ if (f && f !== true) effects.push({ kind: 'read', path: h.resolve(f) })
498
+ else program = ops.shift()
499
+ if (program) h.role(program, 'code')
500
+ const files = ops.filter((o) => !/^[A-Za-z_][A-Za-z0-9_]*=/.test(knownPrefix(o)))
501
+ for (const file of files) effects.push({ kind: 'read', path: h.resolve(file) })
502
+ let complete = !incomplete(p) && program !== undefined
503
+ for (const text of (program && programTexts(program)) || [undefined]) {
504
+ if (text === undefined) {
505
+ complete = false
506
+ continue
507
+ }
508
+ if (/\bsystem\s*\(|\|\s*"|"\s*\|\s*getline/.test(text)) {
509
+ effects.push({ kind: 'unknown', detail: 'awk program runs shell commands' })
510
+ complete = false
511
+ }
512
+ for (const m of text.matchAll(/(?:print|printf)[^;}]*?>>?\s*"([^"]+)"/g)) effects.push({ kind: 'write', path: h.resolve(known(m[1])) })
513
+ if (/(?:print|printf)[^;}]*?>>?\s*[^"\s]/.test(text)) complete = false
514
+ }
515
+ return { effects, readsStdin: files.length === 0, complete }
516
+ },
517
+ },
518
+ {
519
+ names: ['find'],
520
+ summary: 'search a directory tree',
521
+ analyze(inv, h) {
522
+ const a = args(inv)
523
+ const starts: AValue[] = []
524
+ let i = 0
525
+ while (i < a.length && /^-[HLP]$/.test(concrete(a[i]) ?? '')) i++
526
+ for (; i < a.length; i++) {
527
+ const s = concrete(a[i])
528
+ if (s !== undefined && (s.startsWith('-') || s === '(' || s === '!' || s === ')')) break
529
+ starts.push(a[i])
530
+ }
531
+ if (starts.length === 0) starts.push(known('.'))
532
+ const roots = starts.map(h.resolve)
533
+ // find itself reads names and metadata; actions (-exec cat {}) report their own reads
534
+ const effects: Effect[] = roots.map((r) => ({ kind: 'read', path: r, recursive: true, names: true }))
535
+ let complete = true
536
+ // a found path: lies under one of the start dirs
537
+ const foundPath = (): AValue => {
538
+ const r = roots.length === 1 ? concrete(roots[0]) : undefined
539
+ return hole('path', 'find result', r ? { under: r } : {})
540
+ }
541
+ for (; i < a.length; i++) {
542
+ const s = concrete(a[i])
543
+ if (s === undefined) {
544
+ complete = false
545
+ continue
546
+ }
547
+ // only matching files are deleted, and there may be none: a possible effect
548
+ if (s === '-delete') for (const r of roots) effects.push({ kind: 'delete', path: r, recursive: true, maybe: true })
549
+ else if (s === '-fprint' || s === '-fprint0' || s === '-fprintf' || s === '-fls') {
550
+ const f = a[++i]
551
+ if (f) effects.push({ kind: 'write', path: h.resolve(f) })
552
+ if (s === '-fprintf') i++
553
+ } else if (s === '-exec' || s === '-execdir' || s === '-ok' || s === '-okdir') {
554
+ // -execdir runs in the match's directory (somewhere under a start dir), with {} as ./NAME
555
+ const inDir = s.endsWith('dir')
556
+ const r = roots.length === 1 ? concrete(roots[0]) : undefined
557
+ const cmd: AValue[] = []
558
+ for (i++; i < a.length; i++) {
559
+ const t = concrete(a[i])
560
+ if (t === ';' || t === '+') break
561
+ cmd.push(t === '{}' ? (inDir ? concat(known('./'), hole('filename', 'find result')) : foundPath()) : a[i])
562
+ }
563
+ const sub = h.subcommand(cmd, { stdin: 'null', ...(inDir ? { cwd: hole('path', 'find result directory', r ? { under: r } : {}) } : {}) })
564
+ effects.push(...sub.effects.map(possibly))
565
+ if (sub.complete === false) complete = false
566
+ } else if (['-name', '-iname', '-path', '-ipath', '-regex', '-iregex', '-wholename'].includes(s)) {
567
+ if (a[i + 1]) h.role(a[i + 1], 'pattern')
568
+ i++
569
+ } else if (['-name', '-iname', '-path', '-ipath', '-regex', '-iregex', '-type', '-maxdepth', '-mindepth', '-mtime', '-mmin', '-size', '-newer', '-user', '-group', '-perm', '-printf', '-wholename', '-samefile', '-atime', '-ctime', '-links', '-inum', '-uid', '-gid', '-cmin', '-amin', '-newermt', '-context', '-xtype', '-fstype'].includes(s)) {
570
+ i++
571
+ }
572
+ }
573
+ return { effects, readsStdin: false, complete, stdout: { type: 'path', lines: true } }
574
+ },
575
+ },
576
+ {
577
+ names: ['xargs'],
578
+ summary: 'build command lines from stdin',
579
+ analyze(inv, h) {
580
+ // GNU -i[R], -e[EOF], -l[N] take optional attached arguments only
581
+ const a: AValue[] = []
582
+ const raw = args(inv)
583
+ for (let k = 0; k < raw.length; k++) {
584
+ const s = concrete(raw[k])
585
+ if (s === undefined || !s.startsWith('-') || s === '--') {
586
+ a.push(...raw.slice(k))
587
+ break
588
+ }
589
+ const m = /^-([iel])(.*)$/.exec(s)
590
+ if (s === '--replace') a.push(known('-I'), known('{}'))
591
+ else if (!m) a.push(raw[k])
592
+ else if (m[1] === 'i') a.push(known('-I'), known(m[2] || '{}'))
593
+ else if (m[1] === 'l') a.push(known('-L'), known(m[2] || '1'))
594
+ else if (m[2]) a.push(known('-E'), known(m[2]))
595
+ }
596
+ const p = h.parseArgs(a, { short: '0prtxo', shortWithArg: 'EILnPsdaJRS', long: { null: false, 'arg-file': '=a', delimiter: '=d', replace: '=I', 'max-args': '=n', 'max-procs': '=P', 'no-run-if-empty': false, verbose: false, interactive: false, exit: false, 'open-tty': false, 'max-lines': '=L', eof: '=E', 'max-chars': '=s', 'show-limits': false, 'process-slot-var': true }, posix: true })
597
+ const cmd = p.operands.length > 0 ? p.operands : [known('echo')]
598
+ const rep = p.flags.get('I') ?? p.flags.get('J')
599
+ const fromStdin = hole('string', 'xargs input')
600
+ const argv = rep && rep !== true
601
+ ? cmd.map((c) => (concrete(c) !== undefined && concrete(rep) !== undefined && concrete(c)!.includes(concrete(rep)!) ? fromStdin : c))
602
+ : [...cmd, fromStdin]
603
+ const sub = h.subcommand(argv, { stdin: 'null' })
604
+ // runs once per input batch, possibly never (empty input)
605
+ const effects = sub.effects.map(possibly)
606
+ const af = p.flags.get('a')
607
+ if (af && af !== true) effects.push({ kind: 'read', path: h.resolve(af) })
608
+ return { effects, readsStdin: !(af && af !== true), complete: sub.complete !== false && !incomplete(p) }
609
+ },
610
+ },
611
+
612
+ // ---- wrappers that run another command ----
613
+ {
614
+ names: ['env'],
615
+ summary: 'run a command in a modified environment',
616
+ analyze(inv, h) {
617
+ const a = args(inv)
618
+ let cwd = inv.cwd
619
+ let i = 0
620
+ for (; i < a.length; i++) {
621
+ const s = concrete(a[i])
622
+ if (s === undefined) break
623
+ if (s === '-i' || s === '-' || s === '--ignore-environment' || s === '-0' || s === '--null' || s === '-v' || s === '--debug') continue
624
+ if (s === '-C' || s === '--chdir') {
625
+ if (a[i + 1]) cwd = h.resolve(a[i + 1])
626
+ i++
627
+ continue
628
+ }
629
+ if (s.startsWith('--chdir=')) {
630
+ cwd = h.resolve(known(s.slice('--chdir='.length)))
631
+ continue
632
+ }
633
+ if (s === '-u' || s === '-S' || s === '--unset' || s === '--split-string') {
634
+ // -S splits its argument into a command line: not modeled
635
+ if (s === '-S' || s === '--split-string') return { effects: [{ kind: 'unknown', detail: 'env -S command line' }], readsStdin: 'maybe', complete: false }
636
+ i++
637
+ continue
638
+ }
639
+ if (/^--unset=/.test(s)) continue
640
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(s)) continue
641
+ break
642
+ }
643
+ if (i >= a.length) return { effects: [], readsStdin: false }
644
+ return h.subcommand(a.slice(i), { cwd })
645
+ },
646
+ },
647
+ ...['timeout', 'nice', 'nohup', 'time', 'stdbuf', 'ionice', 'caffeinate', 'chrt', 'taskset', 'unbuffer', 'command', 'builtin', 'exec'].map((name): Behavior => ({
648
+ names: [name],
649
+ summary: 'run another command',
650
+ analyze(inv, h) {
651
+ const a = args(inv)
652
+ let i = 0
653
+ const withArg: Record<string, string> = { timeout: 'sk', nice: 'n', stdbuf: 'ioe', ionice: 'cnp', taskset: '', chrt: '', caffeinate: 'tw', command: '', builtin: '', exec: 'a', nohup: '', time: 'fo', unbuffer: '' }
654
+ for (; i < a.length; i++) {
655
+ const s = concrete(a[i])
656
+ if (s === undefined || !s.startsWith('-') || s === '-') break
657
+ if (s === '--') {
658
+ i++
659
+ break
660
+ }
661
+ if (name === 'command' && (s.includes('v') || s.includes('V'))) return { effects: [], readsStdin: false, stdout: { type: 'path' } }
662
+ if (s.length === 2 && withArg[name].includes(s[1])) i++
663
+ }
664
+ if (name === 'timeout') i++ // duration
665
+ if (name === 'taskset' || name === 'chrt') i++
666
+ const rest = a.slice(i)
667
+ if (rest.length === 0) return { effects: [] }
668
+ return h.subcommand(rest)
669
+ },
670
+ })),
671
+ {
672
+ names: ['sudo', 'doas', 'su'],
673
+ summary: 'run a command as another user',
674
+ analyze(inv, h) {
675
+ const a = args(inv)
676
+ let i = 0
677
+ for (; i < a.length; i++) {
678
+ const s = concrete(a[i])
679
+ if (s === undefined || !s.startsWith('-')) break
680
+ if (['-u', '-g', '-C', '-D', '-h', '-p', '-r', '-t', '-U'].includes(s)) i++
681
+ }
682
+ const sub = h.subcommand(a.slice(i))
683
+ return { ...sub, effects: [{ kind: 'process', detail: 'privilege escalation' }, { kind: 'unknown', detail: 'runs with elevated privileges outside the sandbox' }, ...sub.effects], complete: false }
684
+ },
685
+ },
686
+ {
687
+ names: ['bash', 'sh', 'zsh', 'dash', 'ksh'],
688
+ summary: 'run a shell',
689
+ analyze(inv, h) {
690
+ const a = args(inv)
691
+ let i = 0
692
+ let script: AValue | undefined
693
+ for (; i < a.length; i++) {
694
+ const s = concrete(a[i])
695
+ if (s === undefined) break
696
+ if (s === '-c') {
697
+ script = a[++i]
698
+ i++
699
+ break
700
+ }
701
+ if (s === '-o' || s === '-O' || s === '+O' || s === '+o') {
702
+ i++
703
+ continue
704
+ }
705
+ if (/^-[a-zA-Z]+$/.test(s) && !s.includes('c')) continue
706
+ if (/^-[a-zA-Z]*c[a-zA-Z]*$/.test(s)) {
707
+ script = a[++i]
708
+ i++
709
+ break
710
+ }
711
+ break
712
+ }
713
+ if (script) return { ...h.script(script, a.slice(i)), readsStdin: 'maybe' }
714
+ const file = a[i]
715
+ if (!file) return { effects: [{ kind: 'unknown', detail: 'shell reads commands from stdin' }], readsStdin: true, complete: false }
716
+ const text = readScript(h.resolve(file))
717
+ if (text !== undefined) {
718
+ const r = h.script(known(text), a.slice(i + 1), { file: concrete(h.resolve(file)) })
719
+ return { ...r, effects: [{ kind: 'read', path: h.resolve(file) }, ...r.effects], readsStdin: 'maybe' }
720
+ }
721
+ return {
722
+ effects: [{ kind: 'read', path: h.resolve(file) }, { kind: 'unknown', detail: `runs script ${concrete(file) ?? 'file'}` }],
723
+ readsStdin: 'maybe',
724
+ complete: false,
725
+ }
726
+ },
727
+ },
728
+
729
+ // ---- network ----
730
+ {
731
+ names: ['curl'],
732
+ summary: 'transfer a URL',
733
+ analyze(inv, h) {
734
+ const p = h.parseArgs(args(inv), {
735
+ short: 'sSfLIkvOJgiNqZ#lRB0123456', shortWithArg: 'oXHdAuFeTxwmrbcEKYyzCQDtP',
736
+ long: { output: '=o', 'remote-name': false, request: '=X', header: '=H', data: '=d', 'data-raw': true, 'data-binary': true, 'data-urlencode': true, form: '=F', 'upload-file': '=T', user: '=u', silent: false, 'show-error': false, fail: false, location: false, insecure: false, verbose: false, include: false, head: false, 'max-time': '=m', retry: true, 'connect-timeout': true, 'write-out': '=w', cookie: '=b', 'cookie-jar': '=c', 'dump-header': '=D', compressed: false, 'create-dirs': false, json: true, url: true, 'user-agent': '=A', proxy: '=x', 'fail-with-body': false, 'retry-delay': true, 'output-dir': true, 'remote-name-all': false, config: '=K' },
737
+ })
738
+ const effects: Effect[] = []
739
+ const urls = [...p.operands, ...(p.multi.get('url') ?? [])]
740
+ for (const u of urls) effects.push({ kind: 'network', target: u, detail: 'curl' })
741
+ for (const o of p.multi.get('o') ?? []) if (concrete(o) !== '-') effects.push({ kind: 'write', path: h.resolve(o) })
742
+ if (p.flags.has('O') || p.flags.has('remote-name')) effects.push({ kind: 'write', path: h.resolve(hole('filename', 'remote name')) })
743
+ for (const k of ['c', 'D']) {
744
+ const v = p.flags.get(k)
745
+ if (v && v !== true && concrete(v) !== '-') effects.push({ kind: 'write', path: h.resolve(v) })
746
+ }
747
+ for (const k of ['T', 'K']) {
748
+ const v = p.flags.get(k)
749
+ if (v && v !== true) effects.push({ kind: 'read', path: h.resolve(v) })
750
+ }
751
+ for (const d of [...(p.multi.get('d') ?? []), ...(p.multi.get('data-binary') ?? []), ...(p.multi.get('F') ?? [])]) {
752
+ const s = knownPrefix(d)
753
+ if (s.startsWith('@')) effects.push({ kind: 'read', path: h.resolve({ alts: d.alts.map((alt) => [typeof alt[0] === 'string' ? alt[0].slice(1) : '', ...alt.slice(1)]) }) })
754
+ const at = /^[^=]*=@/.exec(s)
755
+ if (at) effects.push({ kind: 'read', path: h.resolve(known(s.slice(at[0].length))) })
756
+ }
757
+ return { effects, readsStdin: false, complete: !incomplete(p) }
758
+ },
759
+ },
760
+ {
761
+ names: ['wget'],
762
+ summary: 'download files',
763
+ analyze(inv, h) {
764
+ const p = h.parseArgs(args(inv), { short: 'qvcNrkpmb', shortWithArg: 'OPoaltTUe', long: { 'output-document': '=O', 'directory-prefix': '=P', quiet: false, 'no-verbose': false, continue: false, recursive: false, mirror: false, 'no-check-certificate': false, header: true, 'user-agent': '=U', timeout: '=T', tries: '=t' } })
765
+ const effects: Effect[] = p.operands.map((u) => ({ kind: 'network', target: u, detail: 'wget' }))
766
+ const out = p.flags.get('O')
767
+ const dir = p.flags.get('P')
768
+ if (out && out !== true) {
769
+ if (concrete(out) !== '-') effects.push({ kind: 'write', path: h.resolve(out) })
770
+ } else {
771
+ const base = dir && dir !== true ? h.resolve(dir) : inv.cwd
772
+ effects.push({ kind: 'write', path: base, recursive: true })
773
+ }
774
+ return { effects, readsStdin: false, complete: !incomplete(p) }
775
+ },
776
+ },
777
+ ...Object.entries(NET_CLIENTS).map(([name, spec]): Behavior => ({
778
+ names: [name],
779
+ summary: 'network client',
780
+ analyze(inv, h) {
781
+ const p = h.parseArgs(args(inv), spec)
782
+ const remote = (v: AValue): boolean => /^[^/]*:/.test(knownPrefix(v))
783
+ const ops = p.operands.filter((o) => !(name === 'dig' && knownPrefix(o).startsWith('+')))
784
+ // the host: dig @server, `host NAME [SERVER]`, `nslookup NAME [SERVER]`; scp's remote operands; else the first operand
785
+ const server = ops.find((o) => knownPrefix(o).startsWith('@'))
786
+ let target: AValue | undefined
787
+ if (name === 'dig') target = server ? { alts: server.alts.map((alt) => [typeof alt[0] === 'string' ? alt[0].slice(1) : alt[0], ...alt.slice(1)]) } : ops.find((o) => o !== server && !/^(A|AAAA|MX|NS|TXT|CNAME|SOA|PTR|SRV|ANY|CAA|HTTPS|IN|CH)$/.test(concrete(o) ?? ''))
788
+ else if (name === 'host' || name === 'nslookup') target = ops[1] ?? ops[0]
789
+ else if (name === 'scp' || name === 'rsync') target = ops.find(remote)
790
+ else target = ops[0]
791
+ const effects: Effect[] = [{ kind: 'network', detail: name, ...(target ? { target } : {}) }]
792
+ // ssh keys and configuration
793
+ if (/^(ssh|scp|sftp)$/.test(name)) {
794
+ for (const k of ['i', 'F']) for (const v of p.multi.get(k) ?? []) effects.push({ kind: 'read', path: h.resolve(v) })
795
+ effects.push({ kind: 'read', path: concat(h.home() ?? known('~'), known('/.ssh')), recursive: true, maybe: true })
796
+ const log = p.flags.get('E')
797
+ if (log && log !== true) effects.push({ kind: 'write', path: h.resolve(log), append: true })
798
+ }
799
+ if (name === 'scp') {
800
+ const files = p.operands
801
+ for (const o of files.slice(0, -1)) if (!remote(o)) effects.push({ kind: 'read', path: h.resolve(o), ...(p.flags.has('r') ? { recursive: true } : {}) })
802
+ const last = files[files.length - 1]
803
+ if (last && !remote(last)) effects.push({ kind: 'write', path: h.resolve(last), ...(p.flags.has('r') ? { recursive: true } : {}) })
804
+ }
805
+ if (name === 'sftp') for (const v of p.multi.get('b') ?? []) if (concrete(v) !== '-') effects.push({ kind: 'read', path: h.resolve(v) })
806
+ if (name === 'dig') for (const k of ['f', 'k']) for (const v of p.multi.get(k) ?? []) effects.push({ kind: 'read', path: h.resolve(v) })
807
+ if (name === 'telnet') for (const v of p.multi.get('n') ?? []) effects.push({ kind: 'write', path: h.resolve(v) })
808
+ // queries are complete; sessions may run remote commands, transfer data, or (ProxyCommand, scp -S, nc -e) run local ones
809
+ const complete = /^(ping|ping6|dig|nslookup|host|traceroute|traceroute6)$/.test(name) && p.unknownFlags.length === 0
810
+ if (!complete) effects.push({ kind: 'unknown', detail: `${name} may run remote commands or transfer data` })
811
+ return { effects, readsStdin: complete ? false : 'maybe', complete }
812
+ },
813
+ })),
814
+
815
+ // ---- archives ----
816
+ {
817
+ names: ['tar', 'bsdtar', 'gtar'],
818
+ summary: 'archive files',
819
+ analyze(inv, h) {
820
+ const a = args(inv)
821
+ // first arg may be a bundled flag word without dash: `tar xzf x.tgz`
822
+ const first = concrete(a[0] ?? known(''))
823
+ const list = first !== undefined && /^[A-Za-z]+$/.test(first) && !first.startsWith('-') ? [known('-' + first), ...a.slice(1)] : a
824
+ const p = h.parseArgs(list, { short: 'cxtrudzjJavpPkmhOSwBiG', shortWithArg: 'fCTXbIL', long: { create: false, extract: false, list: false, file: '=f', directory: '=C', gzip: false, bzip2: false, xz: false, zstd: false, 'auto-compress': false, verbose: false, exclude: true, 'exclude-from': '=X', 'files-from': '=T', null: false, 'no-xattrs': false, xattrs: false, 'keep-old-files': false, 'skip-old-files': false, overwrite: false, 'no-same-owner': false, 'no-same-permissions': false, 'strip-components': true, 'to-stdout': false, append: false, update: false, transform: true, 'exclude-vcs': false, 'owner': true, 'group': true, 'mtime': true, sort: true, 'numeric-owner': false, 'use-compress-program': '=I', wildcards: false, 'no-recursion': false, dereference: false, 'one-file-system': false, totals: false, 'checkpoint': true, 'warning': true, format: true, 'show-transformed-names': false, absolute: false } })
825
+ const archive = p.flags.get('f')
826
+ const dir = p.flags.get('C')
827
+ const base = dir && dir !== true ? h.resolve(dir) : inv.cwd
828
+ const effects: Effect[] = []
829
+ const resolveIn = (v: AValue): AValue => (knownPrefix(v).startsWith('/') ? v : h.resolve(v))
830
+ const toStdin = !archive || archive === true || concrete(archive) === '-'
831
+ // member lists from a file (`-T list`, `-T -`) and compress programs: not modeled
832
+ const filesFrom = p.flags.get('T')
833
+ let complete = !incomplete(p) && !p.flags.has('I')
834
+ if (filesFrom && filesFrom !== true) {
835
+ complete = false
836
+ if (concrete(filesFrom) !== '-') effects.push({ kind: 'read', path: h.resolve(filesFrom) })
837
+ }
838
+ const excludeFrom = p.flags.get('X')
839
+ if (excludeFrom && excludeFrom !== true) effects.push({ kind: 'read', path: h.resolve(excludeFrom) })
840
+ const listOnStdin = filesFrom !== undefined && filesFrom !== true && concrete(filesFrom) === '-'
841
+ if (p.flags.has('c') || p.flags.has('r') || p.flags.has('u') || p.flags.has('create')) {
842
+ for (const o of p.operands) effects.push({ kind: 'read', path: dir && dir !== true ? concatPath(base, o) : resolveIn(o), recursive: true })
843
+ if (!toStdin) effects.push({ kind: 'write', path: h.resolve(archive as AValue) })
844
+ return { effects, readsStdin: listOnStdin, complete }
845
+ }
846
+ if (!toStdin) effects.push({ kind: 'read', path: h.resolve(archive as AValue) })
847
+ if (p.flags.has('x') || p.flags.has('extract')) {
848
+ if (!p.flags.has('O') && !p.flags.has('to-stdout')) effects.push({ kind: 'write', path: base, recursive: true })
849
+ }
850
+ return { effects, readsStdin: toStdin || listOnStdin, complete }
851
+ },
852
+ },
853
+ {
854
+ names: ['unzip'],
855
+ summary: 'extract zip archives',
856
+ analyze(inv, h) {
857
+ const p = h.parseArgs(args(inv), { short: 'lnoqjpCLtvVXa', shortWithArg: 'dxP' })
858
+ const effects: Effect[] = []
859
+ if (p.operands[0]) effects.push({ kind: 'read', path: h.resolve(p.operands[0]) })
860
+ const d = p.flags.get('d')
861
+ if (!p.flags.has('l') && !p.flags.has('p') && !p.flags.has('t')) effects.push({ kind: 'write', path: d && d !== true ? h.resolve(d) : inv.cwd, recursive: true })
862
+ return { effects, readsStdin: false, complete: !incomplete(p) }
863
+ },
864
+ },
865
+ {
866
+ names: ['zip'],
867
+ summary: 'create zip archives',
868
+ analyze(inv, h) {
869
+ const p = h.parseArgs(args(inv), { short: 'rqvjmTyDXF0123456789', shortWithArg: 'xid' })
870
+ const [archive, ...files] = p.operands
871
+ const effects: Effect[] = files.map((f) => ({ kind: 'read', path: h.resolve(f), recursive: p.flags.has('r') }))
872
+ if (archive) effects.push({ kind: 'write', path: h.resolve(archive) })
873
+ if (p.flags.has('m')) for (const f of files) effects.push({ kind: 'delete', path: h.resolve(f), recursive: p.flags.has('r') })
874
+ return { effects, readsStdin: false, complete: !incomplete(p) }
875
+ },
876
+ },
877
+ {
878
+ names: ['gzip', 'gunzip', 'bzip2', 'bunzip2', 'xz', 'unxz', 'zstd', 'unzstd', 'zcat', 'bzcat', 'xzcat'],
879
+ summary: 'compress / decompress',
880
+ analyze(inv, h) {
881
+ const zstd = /zstd/.test(inv.name)
882
+ const p = h.parseArgs(args(inv), { short: 'cdfklnNqrtvzZ0123456789TeFhHLV', shortWithArg: zstd ? 'SoD' : 'S', long: { stdout: '=c', 'to-stdout': '=c', decompress: '=d', uncompress: '=d', keep: '=k', force: '=f', recursive: '=r', list: '=l', test: '=t', suffix: '=S', quiet: '=q', verbose: '=v', best: false, fast: false, rm: false, output: true, 'no-name': false, name: false, threads: true, ultra: false, long: false, rsyncable: false, extreme: false, format: true } })
883
+ const toStdout = p.flags.has('c') || /cat$/.test(inv.name) || p.flags.has('t') || p.flags.has('l')
884
+ const decompress = p.flags.has('d') || /^[gb]?un|cat$/.test(inv.name)
885
+ const recursive = p.flags.has('r')
886
+ const sv = p.flags.get('S')
887
+ const suffix = sv && sv !== true ? sv : known(/bz|bunzip/.test(inv.name) ? '.bz2' : inv.name.includes('xz') ? '.xz' : zstd ? '.zst' : '.gz')
888
+ // zstd keeps its input unless --rm; -o names the output
889
+ const keep = p.flags.has('k') || (zstd && !p.flags.has('rm'))
890
+ const out = p.flags.get('o') ?? p.flags.get('output')
891
+ const effects: Effect[] = []
892
+ for (const f of p.operands) {
893
+ if (isStdinOperand(f)) continue
894
+ const src = h.resolve(f)
895
+ effects.push({ kind: 'read', path: src, ...(recursive ? { recursive } : {}) })
896
+ if (toStdout) continue
897
+ if (out && out !== true) {
898
+ if (!isStdinOperand(out)) effects.push({ kind: 'write', path: h.resolve(out) })
899
+ } else if (recursive) {
900
+ // every file below is replaced by its (de)compressed sibling
901
+ effects.push({ kind: 'write', path: src, recursive: true })
902
+ } else {
903
+ // writes the (de)compressed sibling and removes the original
904
+ const sfx = concrete(suffix)
905
+ const name = concrete(src)
906
+ const target: AValue = !decompress
907
+ ? concat(src, suffix)
908
+ : name !== undefined && sfx !== undefined && name.endsWith(sfx) && name.length > sfx.length
909
+ ? known(name.slice(0, -sfx.length))
910
+ : name !== undefined && /\.(t?gz|bz2|xz|zst|z|Z|tbz2?|txz|tzst)$/.test(name)
911
+ ? known(name.replace(/\.(gz|bz2|xz|zst|z|Z)$/, '').replace(/\.t(gz|bz2?|xz|zst)$/, '.tar'))
912
+ : name !== undefined ? concat(known(name.slice(0, name.lastIndexOf('/') + 1)), hole('filename', 'decompressed name')) : hole('path', 'decompressed file')
913
+ effects.push({ kind: 'write', path: target })
914
+ }
915
+ if (!keep) effects.push({ kind: 'delete', path: src, ...(recursive ? { recursive } : {}) })
916
+ }
917
+ return { effects, readsStdin: p.operands.length === 0 || p.operands.some(isStdinOperand), complete: !incomplete(p) }
918
+ },
919
+ },
920
+
921
+ // ---- process control ----
922
+ {
923
+ names: ['kill', 'pkill', 'killall'],
924
+ summary: 'signal processes',
925
+ analyze: (inv) => ({ effects: [{ kind: 'process', detail: `signal ${args(inv).map((a) => concrete(a) ?? '?').join(' ')}` }], readsStdin: false }),
926
+ },
927
+ ]
928
+
929
+ /** Contents of a script file named by a concrete path (behaviors may read, never write). */
930
+ export function readScript(p: AValue): string | undefined {
931
+ const file = concrete(p)
932
+ if (file === undefined) return undefined
933
+ try {
934
+ const st = fs.statSync(file)
935
+ if (!st.isFile() || st.size > 1024 * 1024) return undefined
936
+ return fs.readFileSync(file, 'utf8')
937
+ } catch {
938
+ return undefined
939
+ }
940
+ }
941
+
942
+ /**
943
+ * The possible texts of a program argument (sed script, awk program): each
944
+ * alternative of a finite value, with integer holes (`$((a+1))`) filled in
945
+ * by a digit, since a number cannot add a command. Undefined when the text
946
+ * has other unknown parts.
947
+ */
948
+ function programTexts(v: AValue): string[] | undefined {
949
+ const out: string[] = []
950
+ for (const alt of v.alts) {
951
+ let s = ''
952
+ for (const seg of alt) {
953
+ if (typeof seg === 'string') s += seg
954
+ else if (seg.type === 'int') s += '0'
955
+ else return undefined
956
+ }
957
+ out.push(s)
958
+ }
959
+ return out
960
+ }
961
+
962
+ /** The same effect, marked as only possible. */
963
+ function possibly(e: Effect): Effect {
964
+ return e.kind === 'read' || e.kind === 'write' || e.kind === 'delete' ? { ...e, maybe: true } : e
965
+ }
966
+
967
+ function concatPath(base: AValue, rel: AValue): AValue {
968
+ if (knownPrefix(rel).startsWith('/')) return rel
969
+ return { alts: base.alts.flatMap((b) => rel.alts.map((r) => [...b, '/', ...r])) }
970
+ }