@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,1395 @@
1
+ /**
2
+ * Static analysis by abstract interpretation.
3
+ *
4
+ * Walks the AST with an abstract environment (variables → {@link AValue}s,
5
+ * abstract cwd, positional parameters), expands every simple command's words
6
+ * abstractly, applies the same behavior definitions the VM uses at runtime,
7
+ * and checks the predicted effects against the policy.
8
+ *
9
+ * Each command site gets a reachability: `must` when it runs on every
10
+ * execution that does not terminate earlier (not under a condition, loop body
11
+ * that may not run, `&&`/`||`, case arm, or uncalled function), else `may`.
12
+ * The report's verdict is `reject` when some `must` site has a definite
13
+ * policy violation — the snippet would certainly violate the policy if it
14
+ * gets that far — and `check-at-runtime` when violations are only possible.
15
+ */
16
+
17
+ import * as os from 'node:os'
18
+ import * as fs from 'node:fs'
19
+ import type * as A from '../parser/ast.ts'
20
+ import { parse, lineColumn, literalOf } from '../parser/parser.ts'
21
+ import type { BehaviorRegistry } from '../behaviors/registry.ts'
22
+ import { basename, resolvePath } from '../behaviors/registry.ts'
23
+ import type { Invocation, OutputInfo, StdinKind } from '../behaviors/types.ts'
24
+ import type { Effect } from '../policy/effects.ts'
25
+ import { describeEffect, withVia, type Via } from '../policy/effects.ts'
26
+ import { checkCommandName, checkEffects, checkExecEffects, shapesOf, worstVerdict, type Policy, type PolicyFinding, type Verdict } from '../policy/policy.ts'
27
+ import { braceExpand } from '../vm/expand.ts'
28
+ import { hasGlobChars, quotePattern, removePrefix, removeSuffix, replacePattern } from '../vm/pattern.ts'
29
+ import { BUILTINS } from '../vm/builtins.ts'
30
+ import { evaluateArith, type ArithEnv } from '../vm/arith.ts'
31
+ import { refineOutput } from './outputs.ts'
32
+ import { AFs, type FsState } from './fs.ts'
33
+ import {
34
+ concat, concrete, describe, hole, guardEmpty, isFullyKnown, isNameLike, isSlashFree, join, known, typeOf, unknownString, type AValue, type HoleType, type Seg,
35
+ } from './value.ts'
36
+
37
+ export interface AnalyzeOptions {
38
+ cwd: AValue
39
+ /** Concrete initial environment. */
40
+ env: Record<string, string>
41
+ /** Abstract environment lookup (nested analysis of `bash -c`). Overrides `env`. */
42
+ envValues?: (name: string) => AValue | undefined
43
+ policy: Policy
44
+ behaviors: BehaviorRegistry
45
+ positional?: AValue[]
46
+ /** Nested analysis (bash -c, eval): suppress uncalled-function reporting. */
47
+ nested?: boolean
48
+ /** Reject definite violations reached on these paths (default: must and happy). */
49
+ rejectOn?: Reach[]
50
+ }
51
+
52
+ /**
53
+ * `must`: runs on every execution that does not terminate earlier.
54
+ * `happy`: runs if every preceding command succeeds (`a && b`, `cd x && cmd`).
55
+ * `may`: runs only under some condition (if/case arms, `||`, loop bodies that may not run).
56
+ */
57
+ export type Reach = 'must' | 'happy' | 'may'
58
+
59
+ export interface CommandSite {
60
+ nodeId: number
61
+ span: A.Span
62
+ line: number
63
+ text: string
64
+ /** Abstractly expanded argv. */
65
+ argv: AValue[]
66
+ /** Human-readable argv (unknowns shown as ⟨origin⟩). */
67
+ display: string
68
+ kind: 'builtin' | 'function' | 'external' | 'not-found' | 'unknown' | 'assignment'
69
+ reach: Reach
70
+ context: { loop: boolean; subshell: boolean; cmdsubst: boolean; pipeline: boolean; background: boolean; function?: string }
71
+ cwd: AValue
72
+ effects: Effect[]
73
+ /** The behavior definition fully determined the effects. */
74
+ complete: boolean
75
+ findings: PolicyFinding[]
76
+ verdict: Verdict
77
+ }
78
+
79
+ export interface VariableInfo {
80
+ name: string
81
+ value: string
82
+ type: HoleType | 'literal' | 'mixed' | 'array'
83
+ }
84
+
85
+ export interface Violation {
86
+ nodeId: number
87
+ line: number
88
+ text: string
89
+ reach: Reach
90
+ verdict: 'deny' | 'unknown'
91
+ reason: string
92
+ /** The effect, with its provenance (`… (via \`rm x\` (s.sh:3))`) for nested commands. */
93
+ effect: string
94
+ /** For effects of nested code (`bash -c`, scripts, `eval`, `source`): the inner commands, outermost first. */
95
+ via?: Via[]
96
+ }
97
+
98
+ export interface AnalysisReport {
99
+ commands: CommandSite[]
100
+ variables: VariableInfo[]
101
+ functions: string[]
102
+ violations: Violation[]
103
+ /** `reject`: a definite violation on a must-reach path. */
104
+ verdict: 'allow' | 'check-at-runtime' | 'reject'
105
+ reasons: string[]
106
+ }
107
+
108
+ interface AVar {
109
+ value: AValue
110
+ array?: AValue[]
111
+ exported: boolean
112
+ unsetMaybe?: boolean
113
+ }
114
+
115
+ interface AField {
116
+ value: AValue
117
+ /** May expand to zero or several fields. */
118
+ spread: boolean
119
+ /** Expands to at least one field (an unmatched glob stays literal). */
120
+ atLeastOne?: boolean
121
+ }
122
+
123
+ class AEnv {
124
+ vars = new Map<string, AVar>()
125
+ cwd: AValue
126
+ positional: AField[]
127
+ terminated = false
128
+ functions = new Map<string, AFunction>()
129
+ /** What the snippet has done to the file system on this path. */
130
+ fs = new AFs()
131
+ /** Shared analysis flags (`mayHaveCreated` affects file-system lookups). */
132
+ flags: { mayHaveCreated: boolean } = { mayHaveCreated: false }
133
+
134
+ constructor(cwd: AValue, positional: AField[]) {
135
+ this.cwd = cwd
136
+ this.positional = positional
137
+ }
138
+
139
+ clone(): AEnv {
140
+ const e = new AEnv(this.cwd, [...this.positional])
141
+ for (const [k, v] of this.vars) e.vars.set(k, { ...v, array: v.array ? [...v.array] : undefined })
142
+ e.terminated = this.terminated
143
+ e.functions = new Map(this.functions)
144
+ e.fs = this.fs.clone()
145
+ e.flags = this.flags
146
+ return e
147
+ }
148
+
149
+ /** The file-system state of a concrete path on this path. */
150
+ fsState(p: string): FsState {
151
+ return this.fs.lookup(p, this.flags.mayHaveCreated)
152
+ }
153
+
154
+ /** Join two environments (control-flow merge). */
155
+ static join(a: AEnv, b: AEnv): AEnv {
156
+ if (a.terminated) return b.clone()
157
+ if (b.terminated) return a.clone()
158
+ const e = new AEnv(join(a.cwd, b.cwd), a.positional.length === b.positional.length ? a.positional.map((f, i) => ({ value: join(f.value, b.positional[i].value), spread: f.spread || b.positional[i].spread })) : [{ value: unknownString('$@'), spread: true }])
159
+ const names = new Set([...a.vars.keys(), ...b.vars.keys()])
160
+ for (const n of names) {
161
+ const x = a.vars.get(n)
162
+ const y = b.vars.get(n)
163
+ if (x && y) e.vars.set(n, { value: join(x.value, y.value), exported: x.exported || y.exported, array: x.array && y.array && x.array.length === y.array.length ? x.array.map((v, i) => join(v, y.array![i])) : x.array || y.array ? [unknownString(`${n}[@]`)] : undefined, unsetMaybe: x.unsetMaybe || y.unsetMaybe })
164
+ else {
165
+ const v = (x ?? y)!
166
+ e.vars.set(n, { ...v, value: join(v.value, known('')), unsetMaybe: true })
167
+ }
168
+ }
169
+ for (const [k, f] of b.functions) e.functions.set(k, f)
170
+ e.flags = a.flags
171
+ e.fs = AFs.join(a.fs, b.fs, a.flags.mayHaveCreated)
172
+ return e
173
+ }
174
+ }
175
+
176
+ interface Ctx {
177
+ reach: Reach
178
+ loop: boolean
179
+ subshell: boolean
180
+ cmdsubst: boolean
181
+ pipeline: boolean
182
+ background: boolean
183
+ function?: string
184
+ stdin: StdinKind
185
+ /** Call depth for inlined functions. */
186
+ depth: number
187
+ }
188
+
189
+ const MAX_CALL_DEPTH = 6
190
+
191
+ export function analyzeScript(script: A.Script, source: string, opts: AnalyzeOptions): AnalysisReport {
192
+ return new Analyzer(source, opts).run(script)
193
+ }
194
+
195
+ /** Parse and analyze in one step. */
196
+ export function analyzeSource(source: string, opts: AnalyzeOptions): AnalysisReport {
197
+ return analyzeScript(parse(source), source, opts)
198
+ }
199
+
200
+ /** State shared by an analyzer and the analyzers of code it inlines (`eval`, `source`, `trap`). */
201
+ interface Shared {
202
+ calledFunctions: Set<string>
203
+ /** An earlier command had unknown effects (it might have created directories). */
204
+ mayHaveCreated: boolean
205
+ /** Concrete paths the snippet writes (a sourced file it wrote is not read from disk). */
206
+ written: Set<string>
207
+ /** An earlier command wrote somewhere not known statically. */
208
+ mayHaveWritten: boolean
209
+ /** Node ids for inlined code. */
210
+ nextId: number
211
+ }
212
+
213
+ /** A function definition and the analyzer whose source it is written in. */
214
+ interface AFunction {
215
+ def: A.FunctionDef
216
+ owner: Analyzer
217
+ }
218
+
219
+ const MAX_INLINE_BYTES = 256 * 1024
220
+
221
+ class Analyzer {
222
+ private readonly sites = new Map<number, CommandSite>()
223
+ private readonly source: string
224
+ private readonly opts: AnalyzeOptions
225
+ private readonly shared: Shared
226
+ /** Where recorded sites go: the report, or the inlining analyzer (adding provenance). */
227
+ private readonly emit: (site: CommandSite) => void
228
+ private lastOutput: AValue | null = null
229
+ /** Typed stdout of each external simple command (by node id), for `$(a | b)`. */
230
+ private readonly outputs = new Map<number, AValue | null>()
231
+
232
+ constructor(source: string, opts: AnalyzeOptions, parent?: { shared: Shared; emit: (site: CommandSite) => void }) {
233
+ this.source = source
234
+ this.opts = opts
235
+ this.shared = parent?.shared ?? { calledFunctions: new Set(), mayHaveCreated: false, written: new Set(), mayHaveWritten: false, nextId: 1_500_000_000 }
236
+ this.emit = parent?.emit ?? ((site) => {
237
+ const existing = this.sites.get(site.nodeId)
238
+ const rank = { must: 2, happy: 1, may: 0 }
239
+ if (existing && rank[existing.reach] > rank[site.reach]) site.reach = existing.reach
240
+ this.sites.set(site.nodeId, site)
241
+ })
242
+ }
243
+
244
+ /**
245
+ * `rm -f` of a path that is missing (on disk, after what the snippet did)
246
+ * does nothing: not a violation. If it may exist, it is only possibly one.
247
+ */
248
+ private missingOk(f: PolicyFinding, env: AEnv): PolicyFinding {
249
+ const e = f.effect
250
+ if (f.verdict !== 'deny' || e.kind !== 'delete' || !e.ifExists || this.opts.policy.missingDeletes !== 'allow') return f
251
+ const paths = e.path.alts.map((a) => concrete(normalizeKnownPath({ alts: [a] })))
252
+ const states = paths.map((p) => (p === undefined ? 'unknown' : env.fsState(p)))
253
+ if (states.every((s) => s === 'absent')) return { ...f, verdict: 'allow', reason: 'delete: nothing to delete (missing path, -f)' }
254
+ if (states.every((s) => s === 'dir' || s === 'file')) return f
255
+ return { ...f, verdict: 'unknown', reason: `${f.reason} (if it exists)` }
256
+ }
257
+
258
+ /** Update the simulated file system with a command's effects (on the path where it succeeds). */
259
+ private applyFs(env: AEnv, effects: Effect[], argv0: string | undefined): void {
260
+ const name = argv0 === undefined ? '' : basename(argv0)
261
+ for (const e of effects) {
262
+ if (e.kind !== 'write' && e.kind !== 'delete') continue
263
+ const c = e.maybe ? undefined : concrete(normalizeKnownPath(e.path))
264
+ if (c === undefined) {
265
+ env.fs.fuzz(shapesOf(e.path))
266
+ continue
267
+ }
268
+ if (e.kind === 'delete') {
269
+ env.fs.remove(c, 'unknown')
270
+ continue
271
+ }
272
+ const up = c.slice(0, c.lastIndexOf('/')) || '/'
273
+ env.fs.mkdirs(up)
274
+ if (name === 'mkdir' || (e.recursive && DIR_CREATORS.has(name))) env.fs.mkdirs(c)
275
+ else if (e.recursive) {
276
+ // chmod -R and the like: the path keeps its kind
277
+ } else if (env.fsState(c) !== 'dir') env.fs.set(c, name === 'mv' || name === 'ln' ? 'unknown' : 'file')
278
+ }
279
+ }
280
+
281
+ /**
282
+ * What a successful or failed test tells about the file system:
283
+ * `[ -d x ]`, `test -f x`, `[[ -e x ]]`, negated with `!`.
284
+ */
285
+ private refine(cmd: A.Command | undefined, env: AEnv, success: boolean): void {
286
+ if (!cmd) return
287
+ const apply = (op: string, w: A.Word, ok: boolean): void => {
288
+ if (op !== '-d' && op !== '-f' && op !== '-e') return
289
+ if (hasSubstitution(w)) return // re-expanding would analyze it again
290
+ const c = concrete(normalizeKnownPath(resolvePath(this.stringValue(w, env, NO_CTX), env.cwd)))
291
+ if (c === undefined || !c.startsWith('/')) return
292
+ const cur = env.fsState(c)
293
+ if (ok) {
294
+ if (op === '-d') env.fs.mkdirs(c)
295
+ else if (op === '-f') {
296
+ env.fs.mkdirs(c.slice(0, c.lastIndexOf('/')) || '/')
297
+ env.fs.set(c, 'file')
298
+ } else if (cur === 'absent') env.fs.set(c, 'unknown')
299
+ } else if (op === '-e') env.fs.remove(c, 'absent')
300
+ else if (cur === (op === '-d' ? 'dir' : 'file')) env.fs.set(c, 'unknown')
301
+ }
302
+ switch (cmd.type) {
303
+ case 'Pipeline':
304
+ if (cmd.stages.length === 1) this.refine(cmd.stages[0], env, cmd.negated ? !success : success)
305
+ return
306
+ case 'CondCommand': {
307
+ let e = cmd.expr
308
+ let ok = success
309
+ while (e.type === 'CondNot' || e.type === 'CondGroup') {
310
+ if (e.type === 'CondNot') ok = !ok
311
+ e = e.type === 'CondNot' ? e.operand : e.expr
312
+ }
313
+ if (e.type === 'CondUnary') apply(e.op, e.operand, ok)
314
+ return
315
+ }
316
+ case 'SimpleCommand': {
317
+ const words = cmd.words.filter((w): w is A.Word => w.type === 'Word')
318
+ if (words.length !== cmd.words.length || cmd.redirs.length > 0) return
319
+ const lits = words.map((w) => literalOf(w))
320
+ let args = words.slice(1)
321
+ if (lits[0] === '[') {
322
+ if (lits[lits.length - 1] !== ']') return
323
+ args = args.slice(0, -1)
324
+ } else if (lits[0] !== 'test') return
325
+ let ok = success
326
+ if (args.length === 3 && literalOf(args[0]) === '!') {
327
+ ok = !ok
328
+ args = args.slice(1)
329
+ }
330
+ if (args.length === 2) {
331
+ const op = literalOf(args[0])
332
+ if (op) apply(op, args[1], ok)
333
+ }
334
+ return
335
+ }
336
+ default:
337
+ return
338
+ }
339
+ }
340
+
341
+ /** Did the snippet (possibly) write `p` or a directory containing it? */
342
+ private mayHaveChanged(p: string): boolean {
343
+ if (this.shared.mayHaveWritten) return true
344
+ for (const w of this.shared.written) if (p === w || p.startsWith(w === '/' ? '/' : w + '/')) return true
345
+ return false
346
+ }
347
+
348
+ /**
349
+ * Analyze inlined code (`eval` text, a sourced file, a trap action) in the
350
+ * current environment. Its sites are reported at the inlining command, with
351
+ * each effect saying which inner command (and file line) it comes from.
352
+ */
353
+ private inline(cmd: A.SimpleCommand, code: string, env: AEnv, ctx: Ctx, file?: string): AEnv | undefined {
354
+ let script: A.Script
355
+ try {
356
+ script = parse(code, { firstId: this.shared.nextId })
357
+ } catch {
358
+ return undefined
359
+ }
360
+ this.shared.nextId = script.id + 1
361
+ const outer = { span: cmd.span, line: this.line(cmd.span), text: this.text(cmd.span) }
362
+ const sub = new Analyzer(code, { ...this.opts, nested: true }, {
363
+ shared: this.shared,
364
+ emit: (site) => {
365
+ const hop = { text: site.text, line: site.line, ...(file ? { file } : {}) }
366
+ this.emit({
367
+ ...site,
368
+ ...outer,
369
+ effects: site.effects.map((e) => withVia(e, hop)),
370
+ findings: site.findings.map((f) => ({ ...f, effect: withVia(f.effect, hop) })),
371
+ })
372
+ },
373
+ })
374
+ return sub.list(script.body, env, { ...ctx, depth: ctx.depth + 1 })
375
+ }
376
+
377
+ run(script: A.Script): AnalysisReport {
378
+ const positional = (this.opts.positional ?? []).map((v) => ({ value: v, spread: false }))
379
+ const env = new AEnv(this.opts.cwd, positional)
380
+ env.flags = this.shared
381
+ for (const [k, v] of Object.entries(this.opts.env)) env.vars.set(k, { value: known(v), exported: true })
382
+ const ctx: Ctx = { reach: 'must', loop: false, subshell: false, cmdsubst: false, pipeline: false, background: false, stdin: 'unknown', depth: 0 }
383
+ const final = this.list(script.body, env, ctx)
384
+ // functions that were defined but never called: analyze as `may` with unknown arguments
385
+ if (!this.opts.nested) {
386
+ for (const [name, fn] of final.functions) {
387
+ // functions of sourced files are library code: only their calls are analyzed
388
+ if (this.shared.calledFunctions.has(name) || fn.owner !== this) continue
389
+ const fenv = final.clone()
390
+ fenv.positional = [{ value: unknownString(`${name} args`), spread: true }]
391
+ fenv.terminated = false
392
+ fn.owner.command(fn.def.body, fenv, { ...ctx, reach: 'may', function: name, depth: 1 })
393
+ }
394
+ }
395
+ const commands = [...this.sites.values()].sort((a, b) => a.span.start - b.span.start)
396
+ const violations: Violation[] = []
397
+ for (const c of commands) {
398
+ for (const f of c.findings) {
399
+ if (f.verdict === 'allow') continue
400
+ violations.push({ nodeId: c.nodeId, line: c.line, text: c.text, reach: c.reach, verdict: f.verdict, reason: f.reason, effect: describeEffect(f.effect), ...(f.effect.via ? { via: f.effect.via } : {}) })
401
+ }
402
+ }
403
+ const rejectOn = this.opts.rejectOn ?? ['must', 'happy']
404
+ const definite = violations.filter((v) => v.verdict === 'deny' && rejectOn.includes(v.reach))
405
+ const verdict = definite.length > 0 ? 'reject' : violations.some((v) => v.verdict === 'deny' || v.verdict === 'unknown') ? 'check-at-runtime' : 'allow'
406
+ const variables: VariableInfo[] = []
407
+ for (const [name, v] of final.vars) {
408
+ if (name in this.opts.env && concrete(v.value) === this.opts.env[name]) continue
409
+ variables.push({ name, value: v.array ? `(${v.array.map(describe).join(' ')})` : describe(v.value), type: v.array ? 'array' : typeOf(v.value) })
410
+ }
411
+ return {
412
+ commands,
413
+ variables,
414
+ functions: [...final.functions.keys()],
415
+ violations,
416
+ verdict,
417
+ reasons: definite.map((v) => `line ${v.line}: \`${v.text}\` would ${v.effect}: ${v.reason}`),
418
+ }
419
+ }
420
+
421
+ private line(span: A.Span): number {
422
+ return lineColumn(this.source, span.start).line
423
+ }
424
+
425
+ private text(span: A.Span): string {
426
+ return this.source.slice(span.start, span.end)
427
+ }
428
+
429
+ // -------------------------------------------------------------------------
430
+ // Statements
431
+ // -------------------------------------------------------------------------
432
+
433
+ private list(stmts: A.Stmt[], env: AEnv, ctx: Ctx): AEnv {
434
+ let e = env
435
+ for (const s of stmts) {
436
+ if (e.terminated) {
437
+ // unreachable code: analyze as `may` for completeness, but it cannot be `must`
438
+ const dead = e.clone()
439
+ dead.terminated = false
440
+ this.stmt(s, dead, { ...ctx, reach: 'may' })
441
+ continue
442
+ }
443
+ e = this.stmt(s, e, ctx)
444
+ }
445
+ return e
446
+ }
447
+
448
+ private stmt(s: A.Stmt, env: AEnv, ctx: Ctx): AEnv {
449
+ if (s.background) {
450
+ // file-system changes of a background job may or may not have happened yet: keep them (an approximation)
451
+ env.fs = this.command(s.command, env.clone(), { ...ctx, background: true, subshell: true }).fs
452
+ return env
453
+ }
454
+ return this.command(s.command, env, ctx)
455
+ }
456
+
457
+ private command(cmd: A.Command, env: AEnv, ctx: Ctx): AEnv {
458
+ switch (cmd.type) {
459
+ case 'SimpleCommand':
460
+ return this.simple(cmd, env, ctx)
461
+ case 'Pipeline': {
462
+ if (cmd.stages.length === 1) {
463
+ env.fs = this.command(cmd.stages[0], env.clone(), ctx).fs
464
+ return env
465
+ }
466
+ // stages run in subshells, concurrently; their file-system changes are kept (in stage order)
467
+ cmd.stages.forEach((st, i) => {
468
+ env.fs = this.command(st, env.clone(), { ...ctx, pipeline: true, subshell: true, stdin: i > 0 ? 'pipe' : ctx.stdin }).fs
469
+ })
470
+ return env
471
+ }
472
+ case 'AndOr': {
473
+ // `a && b`: b runs on the happy path, in the state a left; after any
474
+ // `||`, or after a predicate (`test -f x && ...`), only conditionally.
475
+ // The state after the list joins every point where it can stop.
476
+ // `a && b || c` is `(a && b) || c`: track the states in which the
477
+ // list so far succeeded and failed; `&&` continues from the first,
478
+ // `||` from the second (tests refine them: `[ -d x ] || mkdir x`).
479
+ const outcome = (item: A.Command, out: AEnv, ok: boolean): AEnv => {
480
+ const e = out.clone()
481
+ this.refine(item, e, ok)
482
+ return e
483
+ }
484
+ const first = this.command(cmd.items[0], env, ctx)
485
+ let succ = outcome(cmd.items[0], first, true)
486
+ let fail = outcome(cmd.items[0], first, false)
487
+ let reach: Reach = ctx.reach === 'may' ? 'may' : 'happy'
488
+ for (let i = 1; i < cmd.items.length; i++) {
489
+ const and = cmd.ops[i - 1] === '&&'
490
+ if (!and || isPredicate(cmd.items[i - 1])) reach = 'may'
491
+ const item = cmd.items[i]
492
+ const out = this.command(item, and ? succ : fail, { ...ctx, reach })
493
+ if (and) {
494
+ fail = AEnv.join(fail, outcome(item, out, false))
495
+ succ = outcome(item, out, true)
496
+ } else {
497
+ succ = AEnv.join(succ, outcome(item, out, true))
498
+ fail = outcome(item, out, false)
499
+ }
500
+ }
501
+ return AEnv.join(succ, fail)
502
+ }
503
+ case 'FunctionDef':
504
+ env.functions.set(cmd.name, { def: cmd, owner: this })
505
+ return env
506
+ case 'Group':
507
+ return this.withRedirs(cmd.redirs, env, ctx, () => this.list(cmd.body, env, ctx))
508
+ case 'Subshell': {
509
+ this.withRedirs(cmd.redirs, env, ctx, () => {
510
+ env.fs = this.list(cmd.body, env.clone(), { ...ctx, subshell: true }).fs
511
+ return env
512
+ })
513
+ return env
514
+ }
515
+ case 'If':
516
+ return this.withRedirs(cmd.redirs, env, ctx, () => {
517
+ let e = env
518
+ const outs: AEnv[] = []
519
+ for (let i = 0; i < cmd.clauses.length; i++) {
520
+ const c = cmd.clauses[i]
521
+ e = this.list(c.cond, e, i === 0 ? ctx : { ...ctx, reach: 'may' })
522
+ const last = c.cond[c.cond.length - 1]?.command
523
+ const yes = e.clone()
524
+ this.refine(last, yes, true)
525
+ outs.push(this.list(c.body, yes, { ...ctx, reach: 'may' }))
526
+ e = e.clone()
527
+ this.refine(last, e, false)
528
+ }
529
+ outs.push(cmd.elseBody ? this.list(cmd.elseBody, e.clone(), { ...ctx, reach: 'may' }) : e)
530
+ return outs.reduce((a, b) => AEnv.join(a, b))
531
+ })
532
+ case 'Loop':
533
+ return this.withRedirs(cmd.redirs, env, ctx, () => {
534
+ // condition runs at least once; body may not
535
+ let e = this.list(cmd.cond, env, ctx)
536
+ const inner: Ctx = { ...ctx, reach: 'may', loop: true }
537
+ for (let pass = 0; pass < 2; pass++) {
538
+ const after = this.list(cmd.body, e.clone(), inner)
539
+ const again = this.list(cmd.cond, after, { ...ctx, reach: 'may', loop: true })
540
+ e = AEnv.join(e, again)
541
+ }
542
+ e.terminated = false
543
+ return e
544
+ })
545
+ case 'For':
546
+ return this.withRedirs(cmd.redirs, env, ctx, () => {
547
+ const items = cmd.items === null ? env.positional : this.fields(cmd.items, env, ctx)
548
+ const definitelyRuns = items.length > 0 && items.some((f) => !f.spread || f.atLeastOne)
549
+ let value: AValue = items.length === 0 ? known('') : join(...items.map((f) => f.value))
550
+ if (items.length === 0) value = unknownString(`$${cmd.variable}`)
551
+ let e = env
552
+ const inner: Ctx = { ...ctx, reach: definitelyRuns ? ctx.reach : 'may', loop: true }
553
+ for (let pass = 0; pass < 2; pass++) {
554
+ const body = e.clone()
555
+ body.vars.set(cmd.variable, { value, exported: body.vars.get(cmd.variable)?.exported ?? false })
556
+ const after = this.list(cmd.body, body, inner)
557
+ e = pass === 0 && definitelyRuns ? after : AEnv.join(e, after)
558
+ }
559
+ e.terminated = false
560
+ return e
561
+ })
562
+ case 'ArithFor':
563
+ return this.withRedirs(cmd.redirs, env, ctx, () => {
564
+ let e = env
565
+ for (const ex of [cmd.init, cmd.cond, cmd.step]) if (ex) this.arithAssignments(ex, e)
566
+ for (let pass = 0; pass < 2; pass++) {
567
+ const after = this.list(cmd.body, e.clone(), { ...ctx, reach: 'may', loop: true })
568
+ e = AEnv.join(e, after)
569
+ }
570
+ e.terminated = false
571
+ return e
572
+ })
573
+ case 'Select':
574
+ return this.withRedirs(cmd.redirs, env, ctx, () => {
575
+ const items = cmd.items === null ? env.positional : this.fields(cmd.items, env, ctx)
576
+ const e = env.clone()
577
+ e.vars.set(cmd.variable, { value: items.length ? join(...items.map((f) => f.value)) : unknownString(cmd.variable), exported: false })
578
+ return AEnv.join(env, this.list(cmd.body, e, { ...ctx, reach: 'may', loop: true }))
579
+ })
580
+ case 'Case':
581
+ return this.withRedirs(cmd.redirs, env, ctx, () => {
582
+ const word = this.stringValue(cmd.word, env, ctx)
583
+ const outs: AEnv[] = []
584
+ let anyCatchAll = false
585
+ for (const item of cmd.items) {
586
+ const pats = item.patterns.map((p) => this.stringValue(p, env, ctx))
587
+ // a literal word and literal patterns: decide statically
588
+ const w = concrete(word)
589
+ if (w !== undefined && pats.every((p) => concrete(p) !== undefined)) {
590
+ const { matchPattern } = patternModule
591
+ if (!item.patterns.some((p) => matchPattern(w, this.patternText(p, env, ctx)))) continue
592
+ outs.push(this.list(item.body, env.clone(), ctx))
593
+ anyCatchAll = true
594
+ if (item.terminator === ';;') break
595
+ continue
596
+ }
597
+ if (pats.some((p) => concrete(p) === '*')) anyCatchAll = true
598
+ outs.push(this.list(item.body, env.clone(), { ...ctx, reach: 'may' }))
599
+ }
600
+ if (!anyCatchAll) outs.push(env)
601
+ return outs.length === 0 ? env : outs.reduce((a, b) => AEnv.join(a, b))
602
+ })
603
+ case 'ArithCommand':
604
+ return this.withRedirs(cmd.redirs, env, ctx, () => {
605
+ this.arithAssignments(cmd.expr, env)
606
+ return env
607
+ })
608
+ case 'CondCommand':
609
+ return this.withRedirs(cmd.redirs, env, ctx, () => {
610
+ this.condWords(cmd.expr, env, ctx)
611
+ return env
612
+ })
613
+ }
614
+ }
615
+
616
+ private withRedirs(redirs: A.Redirect[], env: AEnv, ctx: Ctx, fn: () => AEnv): AEnv {
617
+ if (redirs.length > 0) {
618
+ const effects = this.redirectEffects(redirs, env, ctx)
619
+ if (effects.length > 0) {
620
+ const r = redirs[0]
621
+ this.record({
622
+ nodeId: r.id, span: r.span, argv: [], kind: 'builtin', env, ctx, effects, complete: true, display: redirs.map((x) => this.text(x.span)).join(' '),
623
+ })
624
+ }
625
+ }
626
+ return fn()
627
+ }
628
+
629
+ private condWords(e: A.CondExpr, env: AEnv, ctx: Ctx): void {
630
+ switch (e.type) {
631
+ case 'CondWord': this.stringValue(e.word, env, ctx); break
632
+ case 'CondUnary': this.stringValue(e.operand, env, ctx); break
633
+ case 'CondBinary': this.stringValue(e.left, env, ctx); this.stringValue(e.right, env, ctx); break
634
+ case 'CondNot': this.condWords(e.operand, env, ctx); break
635
+ case 'CondAndOr': this.condWords(e.left, env, ctx); this.condWords(e.right, env, ctx); break
636
+ case 'CondGroup': this.condWords(e.expr, env, ctx); break
637
+ }
638
+ }
639
+
640
+ /** Variables assigned inside arithmetic become int-typed. */
641
+ private arithAssignments(e: A.ArithExpr, env: AEnv): void {
642
+ const visit = (x: A.ArithExpr): void => {
643
+ switch (x.type) {
644
+ case 'ArithAssign':
645
+ env.vars.set(x.target.name, { value: hole('int', `$${x.target.name}`), exported: env.vars.get(x.target.name)?.exported ?? false })
646
+ visit(x.value)
647
+ break
648
+ case 'ArithIncDec':
649
+ env.vars.set(x.target.name, { value: hole('int', `$${x.target.name}`), exported: env.vars.get(x.target.name)?.exported ?? false })
650
+ break
651
+ case 'ArithBinary': visit(x.left); visit(x.right); break
652
+ case 'ArithUnary': visit(x.operand); break
653
+ case 'ArithTernary': visit(x.cond); visit(x.then); visit(x.else); break
654
+ case 'ArithGroup': visit(x.expr); break
655
+ default: break
656
+ }
657
+ }
658
+ visit(e)
659
+ }
660
+
661
+ // -------------------------------------------------------------------------
662
+ // Simple commands
663
+ // -------------------------------------------------------------------------
664
+
665
+ private simple(cmd: A.SimpleCommand, env: AEnv, ctx: Ctx): AEnv {
666
+ const argvFields: AField[] = []
667
+ const declAssigns: A.Assign[] = []
668
+ for (const w of cmd.words) {
669
+ if (w.type === 'Assign') {
670
+ declAssigns.push(w)
671
+ argvFields.push({ value: known(this.text(w.span)), spread: false })
672
+ } else argvFields.push(...this.fields([w], env, ctx))
673
+ }
674
+ const redirEffects = this.redirectEffects(cmd.redirs, env, ctx)
675
+ if (argvFields.length === 0) {
676
+ for (const a of cmd.assigns) this.assign(a, env, ctx, false)
677
+ if (redirEffects.length > 0 || cmd.assigns.some((a) => a.value?.type === 'Word' && a.value.parts.some((p) => p.type === 'CmdSubst'))) {
678
+ this.record({ nodeId: cmd.id, span: cmd.span, argv: [], kind: 'assignment', env, ctx, effects: redirEffects, complete: true })
679
+ }
680
+ return env
681
+ }
682
+ const argv = argvFields.map((f) => f.value)
683
+ const spread = argvFields.some((f) => f.spread)
684
+ const name = concrete(argv[0])
685
+ const prefixEnv = env.clone()
686
+ for (const a of cmd.assigns) this.assign(a, prefixEnv, ctx, true)
687
+
688
+ if (name === undefined) {
689
+ this.record({
690
+ nodeId: cmd.id, span: cmd.span, argv, kind: 'unknown', env, ctx,
691
+ effects: [...redirEffects, { kind: 'unknown', detail: `command name ${describe(argv[0])} is not known statically` }], complete: false,
692
+ })
693
+ return env
694
+ }
695
+
696
+ // functions (inline at the call site)
697
+ const fn = env.functions.get(name)
698
+ if (fn) {
699
+ this.shared.calledFunctions.add(name)
700
+ this.record({ nodeId: cmd.id, span: cmd.span, argv, kind: 'function', env, ctx, effects: redirEffects, complete: true })
701
+ if (ctx.depth >= MAX_CALL_DEPTH) return env
702
+ const fenv = prefixEnv.clone()
703
+ fenv.positional = argvFields.slice(1)
704
+ const out = fn.owner.command(fn.def.body, fenv, { ...ctx, function: name, depth: ctx.depth + 1 })
705
+ // positional parameters are restored after the call; `exit` inside the function terminates the caller too
706
+ out.positional = env.positional
707
+ return out
708
+ }
709
+
710
+ if (BUILTINS.has(name)) return this.builtin(name, cmd, argvFields, declAssigns, env, prefixEnv, ctx, redirEffects)
711
+
712
+ // external
713
+ const inv = this.invocation(argv, prefixEnv, ctx)
714
+ const r = this.opts.behaviors.analyze(inv)
715
+ const effects = [...redirEffects, ...r.effects]
716
+ if (r.complete === false) this.shared.mayHaveCreated = true
717
+ const nameFinding = checkCommandName(name, this.opts.policy)
718
+ this.record({ nodeId: cmd.id, span: cmd.span, argv, kind: 'external', env, ctx, effects, complete: r.complete !== false && !spread, extraFindings: nameFinding ? [nameFinding] : [] })
719
+ this.lastOutput = this.outputValue(refineOutput(inv, r.stdout), name)
720
+ this.outputs.set(cmd.id, this.lastOutput)
721
+ return env
722
+ }
723
+
724
+ private outputValue(info: OutputInfo | undefined, name: string): AValue | null {
725
+ if (!info) return null
726
+ if (info.value) return info.value
727
+ return hole(info.type, `$(${name})`, info.under ? { under: info.under } : {})
728
+ }
729
+
730
+ private invocation(argv: AValue[], env: AEnv, ctx: Ctx): Invocation {
731
+ return {
732
+ name: basename(concrete(argv[0]) ?? '?'),
733
+ argv,
734
+ cwd: env.cwd,
735
+ env: (n) => {
736
+ const v = env.vars.get(n)
737
+ if (v) return v.value
738
+ return this.opts.envValues?.(n)
739
+ },
740
+ stdin: ctx.stdin,
741
+ mayHaveChanged: (p) => this.mayHaveChanged(p),
742
+ }
743
+ }
744
+
745
+ private builtin(name: string, cmd: A.SimpleCommand, fields: AField[], declAssigns: A.Assign[], env: AEnv, prefixEnv: AEnv, ctx: Ctx, redirEffects: Effect[]): AEnv {
746
+ const argv = fields.map((f) => f.value)
747
+ const args = argv.slice(1)
748
+ const rec = (effects: Effect[] = [], complete = true): void => {
749
+ this.record({ nodeId: cmd.id, span: cmd.span, argv, kind: 'builtin', env, ctx, effects: [...redirEffects, ...effects], complete })
750
+ }
751
+ switch (name) {
752
+ case 'cd':
753
+ case 'pushd': {
754
+ const target = args[0] ?? env.vars.get('HOME')?.value ?? unknownString('$HOME')
755
+ const t = concrete(target)
756
+ const dest = normalizeKnownPath(t === '-' ? (env.vars.get('OLDPWD')?.value ?? hole('path', '$OLDPWD')) : resolvePath(target, env.cwd))
757
+ rec([{ kind: 'chdir', path: dest }])
758
+ // `cd` only changes directory if it succeeds: a concrete target that is
759
+ // not a directory now leaves the cwd as is; an unknown one may or may not
760
+ const d = concrete(dest)
761
+ const st = d === undefined ? 'unknown' : env.fsState(d)
762
+ // a concrete path that is not a directory (now, after what the snippet did): cd fails
763
+ if (st === 'absent' || st === 'file') return env
764
+ env.vars.set('OLDPWD', { value: env.cwd, exported: true })
765
+ env.cwd = st === 'dir' ? dest : join(env.cwd, dest)
766
+ env.vars.set('PWD', { value: env.cwd, exported: true })
767
+ return env
768
+ }
769
+ case 'popd':
770
+ rec()
771
+ env.cwd = hole('path', 'popd')
772
+ return env
773
+ case 'export':
774
+ case 'declare':
775
+ case 'typeset':
776
+ case 'local':
777
+ case 'readonly': {
778
+ rec()
779
+ for (const a of declAssigns) this.assign(a, env, ctx, name === 'export')
780
+ for (const f of args) {
781
+ const s = concrete(f)
782
+ if (s === undefined || s.startsWith('-')) continue
783
+ const eq = s.indexOf('=')
784
+ if (eq > 0 && !s.startsWith('\u0000')) {
785
+ if (declAssigns.some((a) => this.text(a.span) === s)) continue
786
+ env.vars.set(s.slice(0, eq), { value: known(s.slice(eq + 1)), exported: name === 'export' })
787
+ } else if (name === 'export') {
788
+ const v = env.vars.get(s)
789
+ if (v) v.exported = true
790
+ }
791
+ }
792
+ return env
793
+ }
794
+ case 'unset':
795
+ rec()
796
+ for (const a of args) {
797
+ const s = concrete(a)
798
+ if (s && !s.startsWith('-')) env.vars.delete(s)
799
+ }
800
+ return env
801
+ case 'read':
802
+ case 'mapfile':
803
+ case 'readarray': {
804
+ rec()
805
+ const names: string[] = []
806
+ for (let i = 0; i < args.length; i++) {
807
+ const s = concrete(args[i])
808
+ if (s === undefined) continue
809
+ if (s === '-a' || (name !== 'read' && !s.startsWith('-'))) {
810
+ const n = s === '-a' ? concrete(args[++i] ?? known('')) : s
811
+ if (n) env.vars.set(n, { value: unknownString(`$${n}`), array: [unknownString(`${n}[@]`)], exported: false })
812
+ continue
813
+ }
814
+ if (['-d', '-n', '-N', '-p', '-t', '-u', '-i', '-C', '-c', '-O', '-s'].includes(s)) {
815
+ i++
816
+ continue
817
+ }
818
+ if (s.startsWith('-')) continue
819
+ names.push(s)
820
+ }
821
+ if (name === 'read') {
822
+ for (const n of names.length ? names : ['REPLY']) env.vars.set(n, { value: unknownString(`$${n}`), exported: env.vars.get(n)?.exported ?? false })
823
+ } else if (names.length === 0) env.vars.set('MAPFILE', { value: unknownString('MAPFILE'), array: [unknownString('MAPFILE[@]')], exported: false })
824
+ return env
825
+ }
826
+ case 'printf': {
827
+ rec()
828
+ const i = args.findIndex((a) => concrete(a) === '-v')
829
+ if (i >= 0) {
830
+ const n = concrete(args[i + 1] ?? known(''))
831
+ if (n) env.vars.set(n, { value: unknownString(`$${n}`), exported: false })
832
+ }
833
+ return env
834
+ }
835
+ case 'set': {
836
+ rec()
837
+ const i = args.findIndex((a) => concrete(a) === '--')
838
+ if (i >= 0) env.positional = fields.slice(i + 2)
839
+ else if (args.length > 0 && !/^[-+]/.test(concrete(args[0]) ?? '-')) env.positional = fields.slice(1)
840
+ return env
841
+ }
842
+ case 'shift': {
843
+ rec()
844
+ const n = Number(concrete(args[0] ?? known('1')) ?? NaN)
845
+ if (Number.isInteger(n) && !env.positional.some((f) => f.spread)) env.positional = env.positional.slice(n)
846
+ else env.positional = [{ value: unknownString('$@'), spread: true }]
847
+ return env
848
+ }
849
+ case 'exit':
850
+ rec()
851
+ if (ctx.subshell) return env
852
+ env.terminated = true
853
+ return env
854
+ case 'return':
855
+ rec()
856
+ if (ctx.function) env.terminated = true
857
+ return env
858
+ case 'exec': {
859
+ const rest = args.filter((a, i) => !(i === 0 && (concrete(a) ?? '').startsWith('-')))
860
+ if (rest.length === 0) {
861
+ rec()
862
+ return env
863
+ }
864
+ const r = this.opts.behaviors.analyze(this.invocation(rest, prefixEnv, ctx))
865
+ rec(r.effects, r.complete !== false)
866
+ if (!ctx.subshell) env.terminated = true
867
+ return env
868
+ }
869
+ case 'command':
870
+ case 'builtin':
871
+ case 'time': {
872
+ const rest = args.filter((a) => !(concrete(a) ?? '').startsWith('-'))
873
+ if (rest.length === 0 || args.some((a) => ['-v', '-V'].includes(concrete(a) ?? ''))) {
874
+ rec()
875
+ return env
876
+ }
877
+ const sub = concrete(rest[0])
878
+ if (sub && BUILTINS.has(sub)) {
879
+ return this.builtin(sub, cmd, [{ value: rest[0], spread: false }, ...rest.slice(1).map((v) => ({ value: v, spread: false }))], [], env, prefixEnv, ctx, redirEffects)
880
+ }
881
+ const r = this.opts.behaviors.analyze(this.invocation(rest, prefixEnv, ctx))
882
+ rec(r.effects, r.complete !== false)
883
+ return env
884
+ }
885
+ case 'eval': {
886
+ const text = concat(...args.flatMap((a, i) => (i === 0 ? [a] : [known(' '), a])))
887
+ const t = concrete(text)
888
+ if (t === undefined) {
889
+ rec([{ kind: 'unknown', detail: `eval of ${describe(text)}` }], false)
890
+ return env
891
+ }
892
+ rec()
893
+ return this.inline(cmd, t, env, ctx) ?? env
894
+ }
895
+ case 'source':
896
+ case '.': {
897
+ const file = args[0]
898
+ if (!file) {
899
+ rec()
900
+ return env
901
+ }
902
+ const p = normalizeKnownPath(resolvePath(file, env.cwd))
903
+ const read: Effect = { kind: 'read', path: p }
904
+ const c = concrete(p)
905
+ // follow a file that exists now and that the snippet has not (possibly) written
906
+ const text = c !== undefined && !this.mayHaveChanged(c) && ctx.depth < MAX_CALL_DEPTH ? readSmallFile(c) : undefined
907
+ if (text === undefined) {
908
+ rec([read, { kind: 'unknown', detail: `source of ${describe(p)}: contents not known statically` }], false)
909
+ return env
910
+ }
911
+ rec([read])
912
+ const saved = env.positional
913
+ if (args.length > 1) env.positional = fields.slice(2)
914
+ const out = this.inline(cmd, text, env, ctx, c) ?? env
915
+ if (args.length > 1) out.positional = saved
916
+ return out
917
+ }
918
+ case 'trap': {
919
+ rec()
920
+ const action = concrete(args[0] ?? known(''))
921
+ if (action && args.length > 1 && action !== '-') this.inline(cmd, action, env.clone(), { ...ctx, reach: 'may' })
922
+ return env
923
+ }
924
+ case 'kill':
925
+ rec([{ kind: 'process', detail: `kill ${args.map(describe).join(' ')}` }])
926
+ return env
927
+ default:
928
+ rec()
929
+ return env
930
+ }
931
+ }
932
+
933
+ private assign(a: A.Assign, env: AEnv, ctx: Ctx, exported: boolean): void {
934
+ const prev = env.vars.get(a.name)
935
+ if (a.value?.type === 'ArrayLiteral') {
936
+ const elems = a.value.elems.flatMap((el) => this.fields([el.value], env, ctx).map((f) => f.value))
937
+ env.vars.set(a.name, { value: elems[0] ?? known(''), array: elems, exported: exported || (prev?.exported ?? false) })
938
+ return
939
+ }
940
+ this.lastOutput = null
941
+ let value = a.value ? this.stringValue(a.value, env, ctx, true) : known('')
942
+ // `x=$(cmd)`: use the command's inferred output type
943
+ if (a.value && a.value.parts.length === 1 && a.value.parts[0].type === 'CmdSubst' && this.lastOutput && !isFullyKnown(value)) value = this.lastOutput
944
+ if (a.index) {
945
+ env.vars.set(a.name, { value: join(prev?.value ?? known(''), value), array: [unknownString(`${a.name}[@]`)], exported: prev?.exported ?? false })
946
+ return
947
+ }
948
+ if (a.append) value = concat(prev?.value ?? known(''), value)
949
+ env.vars.set(a.name, { value, exported: exported || (prev?.exported ?? false) })
950
+ }
951
+
952
+ private redirectEffects(redirs: A.Redirect[], env: AEnv, ctx: Ctx): Effect[] {
953
+ const effects: Effect[] = []
954
+ for (const r of redirs) {
955
+ if (r.op === '<<' || r.op === '<<-' || r.op === '<<<') {
956
+ if (r.heredoc && !r.heredoc.quoted) this.stringValue(r.heredoc.body, env, ctx)
957
+ continue
958
+ }
959
+ if (r.op === '<&' || r.op === '>&') {
960
+ const t = literalOf(r.target)
961
+ if (t !== null && /^(\d+-?|-)$/.test(t)) continue
962
+ }
963
+ const target = guardEmpty(this.stringValue(r.target, env, ctx))
964
+ const t = concrete(target)
965
+ if (t === '/dev/null' || t === '/dev/stdout' || t === '/dev/stderr' || t === '/dev/stdin' || (t && /^\/dev\/fd\/\d+$/.test(t))) continue
966
+ const p = resolvePath(target, env.cwd)
967
+ if (r.op === '<') effects.push({ kind: 'read', path: p, source: 'redirect' })
968
+ else if (r.op === '<>') effects.push({ kind: 'read', path: p, source: 'redirect' }, { kind: 'write', path: p, source: 'redirect' })
969
+ else effects.push({ kind: 'write', path: p, append: r.op === '>>' || r.op === '&>>', source: 'redirect' })
970
+ }
971
+ return effects
972
+ }
973
+
974
+ private record(r: {
975
+ nodeId: number; span: A.Span; argv: AValue[]; kind: CommandSite['kind']; env: AEnv; ctx: Ctx; effects: Effect[]; complete: boolean; display?: string; extraFindings?: PolicyFinding[]
976
+ }): void {
977
+ const findings = [...checkExecEffects(r.effects, this.opts.policy), ...(r.extraFindings ?? []), ...checkEffects(r.effects, this.opts.policy).map((f) => this.missingOk(f, r.env))]
978
+ for (const e of r.effects) {
979
+ if (e.kind !== 'write' && e.kind !== 'delete') continue
980
+ const c = concrete(normalizeKnownPath(e.path))
981
+ if (c !== undefined) this.shared.written.add(c)
982
+ else this.shared.mayHaveWritten = true
983
+ }
984
+ if (!r.complete) this.shared.mayHaveWritten = true
985
+ this.applyFs(r.env, r.effects, r.argv[0] ? concrete(r.argv[0]) : undefined)
986
+ this.emit({
987
+ nodeId: r.nodeId,
988
+ span: r.span,
989
+ line: this.line(r.span),
990
+ text: this.text(r.span),
991
+ argv: r.argv,
992
+ display: r.display ?? r.argv.map(describe).join(' '),
993
+ kind: r.kind,
994
+ reach: r.ctx.reach,
995
+ context: { loop: r.ctx.loop, subshell: r.ctx.subshell, cmdsubst: r.ctx.cmdsubst, pipeline: r.ctx.pipeline, background: r.ctx.background, function: r.ctx.function },
996
+ cwd: r.env.cwd,
997
+ effects: r.effects,
998
+ complete: r.complete,
999
+ findings,
1000
+ verdict: worstVerdict(findings),
1001
+ })
1002
+ }
1003
+
1004
+ // -------------------------------------------------------------------------
1005
+ // Abstract expansion
1006
+ // -------------------------------------------------------------------------
1007
+
1008
+ private patternText(w: A.Word, env: AEnv, ctx: Ctx): string {
1009
+ let s = ''
1010
+ for (const p of w.parts) {
1011
+ const v = concrete(this.partValue(p, env, ctx, false)) ?? '*'
1012
+ s += p.type === 'Lit' ? v : quotePattern(v)
1013
+ }
1014
+ return s
1015
+ }
1016
+
1017
+ /** Expand a word without splitting (assignment values, redirect targets). */
1018
+ stringValue(w: A.Word, env: AEnv, ctx: Ctx, assignment = false): AValue {
1019
+ const parts = w.parts.map((p, i) => this.partValue(p, env, ctx, false, i === 0 || assignment))
1020
+ return concat(...parts)
1021
+ }
1022
+
1023
+ /** Expand words to abstract fields (splitting and globbing approximated). */
1024
+ fields(words: A.Word[], env: AEnv, ctx: Ctx): AField[] {
1025
+ const out: AField[] = []
1026
+ for (const word of words) {
1027
+ for (const w of braceExpand(word)) {
1028
+ // "$@" and unquoted unknown expansions spread
1029
+ if (w.parts.length === 1 && w.parts[0].type === 'DblQuoted' && w.parts[0].parts.length === 1 && w.parts[0].parts[0].type === 'ParamExp' && isAllParam(w.parts[0].parts[0])) {
1030
+ out.push(...this.allParamFields(w.parts[0].parts[0], env))
1031
+ continue
1032
+ }
1033
+ if (w.parts.length === 1 && w.parts[0].type === 'ParamExp' && isAllParam(w.parts[0])) {
1034
+ out.push(...this.allParamFields(w.parts[0], env).map((f) => ({ ...f, spread: f.spread || !isFullyKnown(f.value) })))
1035
+ continue
1036
+ }
1037
+ let spread = false
1038
+ const vals: AValue[] = []
1039
+ w.parts.forEach((p, i) => {
1040
+ const v = this.partValue(p, env, ctx, true, i === 0)
1041
+ if ((p.type === 'ParamExp' || p.type === 'CmdSubst') && !isFullyKnown(v)) spread = true
1042
+ vals.push(v)
1043
+ })
1044
+ let value = concat(...vals)
1045
+ // unquoted known expansions: split on whitespace
1046
+ const c = concrete(value)
1047
+ const hasUnquotedExpansion = w.parts.some((p) => p.type === 'ParamExp' || p.type === 'CmdSubst' || p.type === 'ArithExp')
1048
+ if (c !== undefined && hasUnquotedExpansion) {
1049
+ const pieces = c.split(/[ \t\n]+/).filter((x) => x !== '')
1050
+ for (const piece of pieces) out.push({ value: known(piece), spread: false })
1051
+ continue
1052
+ }
1053
+ // globs in unquoted literal parts
1054
+ const globbed = this.globValue(w, value)
1055
+ if (globbed) {
1056
+ value = globbed
1057
+ out.push({ value, spread: true, atLeastOne: !spread })
1058
+ continue
1059
+ }
1060
+ out.push({ value: guardEmpty(value), spread })
1061
+ }
1062
+ }
1063
+ return out
1064
+ }
1065
+
1066
+ /** `*.txt` → `⟨*.txt:filename⟩`, `/etc/*.conf` → `/etc/⟨*.conf:filename⟩`. */
1067
+ private globValue(w: A.Word, value: AValue): AValue | null {
1068
+ if (!w.parts.some((p) => p.type === 'Lit' && hasGlobChars(p.value))) return null
1069
+ const c = concrete(value)
1070
+ if (c === undefined) return null
1071
+ const comps = c.split('/')
1072
+ const segs: AValue[] = []
1073
+ comps.forEach((comp, i) => {
1074
+ if (i > 0) segs.push(known('/'))
1075
+ if (hasGlobChars(comp)) segs.push(hole('filename', comp, { pattern: comp }))
1076
+ else segs.push(known(comp))
1077
+ })
1078
+ return concat(...segs)
1079
+ }
1080
+
1081
+ private allParamFields(p: A.ParamExp, env: AEnv): AField[] {
1082
+ if (p.name === '@' || p.name === '*') return env.positional
1083
+ const v = env.vars.get(p.name)
1084
+ if (v?.array) return v.array.map((value) => ({ value, spread: !isFullyKnown(value) }))
1085
+ return [{ value: unknownString(`\${${p.name}[@]}`), spread: true }]
1086
+ }
1087
+
1088
+ private partValue(p: A.WordPart, env: AEnv, ctx: Ctx, _split: boolean, first = false): AValue {
1089
+ switch (p.type) {
1090
+ case 'Lit': {
1091
+ if (first && p.value.startsWith('~')) {
1092
+ // with HOME unset, bash uses the user's home directory from the password database
1093
+ const home = env.vars.get('HOME')?.value ?? this.opts.envValues?.('HOME') ?? known(this.opts.policy.home ?? os.homedir())
1094
+ const slash = p.value.indexOf('/')
1095
+ const user = slash === -1 ? p.value.slice(1) : p.value.slice(1, slash)
1096
+ if (user === '') return concat(home, known(slash === -1 ? '' : p.value.slice(slash)))
1097
+ }
1098
+ return known(p.value)
1099
+ }
1100
+ case 'SglQuoted':
1101
+ case 'Escaped':
1102
+ return known(p.value)
1103
+ case 'DblQuoted':
1104
+ return concat(...p.parts.map((q) => this.partValue(q, env, ctx, false)))
1105
+ case 'ParamExp':
1106
+ return this.param(p, env, ctx)
1107
+ case 'CmdSubst':
1108
+ return this.cmdSubst(p, env, ctx)
1109
+ case 'ArithExp':
1110
+ return this.arith(p.expr, env)
1111
+ case 'ProcSubst':
1112
+ env.fs = this.list(p.body, env.clone(), { ...ctx, subshell: true, cmdsubst: true }).fs
1113
+ return known('/dev/fd/63')
1114
+ }
1115
+ }
1116
+
1117
+ private cmdSubst(p: A.CmdSubst, env: AEnv, ctx: Ctx): AValue {
1118
+ const saved = this.lastOutput
1119
+ this.lastOutput = null
1120
+ env.fs = this.list(p.body, env.clone(), { ...ctx, subshell: true, cmdsubst: true }).fs
1121
+ this.lastOutput = saved
1122
+ // the output is that of the body's last command: `$(cmd)`, `$(a | b)`
1123
+ const last = p.body.length === 1 ? finalSimple(p.body[0].command) : undefined
1124
+ const out = last ? this.outputs.get(last.id) : undefined
1125
+ if (out) {
1126
+ this.lastOutput = out
1127
+ return out
1128
+ }
1129
+ // $(pwd) / $(cd x && pwd)
1130
+ const text = this.text(p.span)
1131
+ if (/^\$\(\s*pwd\s*\)$/.test(text)) return env.cwd
1132
+ return hole('string', text.length > 40 ? text.slice(0, 37) + '...' : text)
1133
+ }
1134
+
1135
+ private arith(expr: A.ArithExpr, env: AEnv): AValue {
1136
+ let ok = true
1137
+ const aenv: ArithEnv = {
1138
+ get: (name) => {
1139
+ const v = env.vars.get(name)
1140
+ if (!v) {
1141
+ // unknown to the analysis (RANDOM, SECONDS, unset or environment-dependent): the result is unknown
1142
+ ok = false
1143
+ return undefined
1144
+ }
1145
+ const c = concrete(v.value)
1146
+ if (c === undefined) ok = false
1147
+ return c ?? '0'
1148
+ },
1149
+ isAssoc: () => false,
1150
+ set: () => {},
1151
+ expandWord: async (w) => {
1152
+ const v = concrete(this.stringValue(w, env, { reach: 'may', loop: false, subshell: false, cmdsubst: false, pipeline: false, background: false, stdin: 'unknown', depth: 0 }))
1153
+ if (v === undefined) ok = false
1154
+ return v ?? '0'
1155
+ },
1156
+ nounset: false,
1157
+ }
1158
+ return syncArith(expr, aenv, () => ok) ?? hole('int', '$((…))')
1159
+ }
1160
+
1161
+ private param(p: A.ParamExp, env: AEnv, ctx: Ctx): AValue {
1162
+ const special = (): AValue | undefined => {
1163
+ switch (p.name) {
1164
+ case '?': case '#': case '$': case '!': return hole('int', `$${p.name}`)
1165
+ case '0': return known('mash')
1166
+ case '-': return hole('string', '$-')
1167
+ case '@': case '*': {
1168
+ const fs = env.positional
1169
+ if (fs.length === 0) return known('')
1170
+ return concat(...fs.flatMap((f, i) => (i === 0 ? [f.value] : [known(' '), f.value])))
1171
+ }
1172
+ }
1173
+ if (/^\d+$/.test(p.name)) {
1174
+ const f = env.positional[Number(p.name) - 1]
1175
+ if (env.positional.some((x) => x.spread)) return unknownString(`$${p.name}`)
1176
+ return f?.value ?? known('')
1177
+ }
1178
+ return undefined
1179
+ }
1180
+ if (p.length) return hole('int', `\${#${p.name}}`)
1181
+ if (p.indirect || p.prefixNames) return unknownString(this.text(p.span))
1182
+ let base: AValue | undefined = special()
1183
+ let unset = false
1184
+ if (base === undefined) {
1185
+ const v = env.vars.get(p.name)
1186
+ if (p.index && v?.array) {
1187
+ const idx = literalOf(p.index)
1188
+ if (idx === '@' || idx === '*') base = v.array.length ? concat(...v.array.flatMap((x, i) => (i === 0 ? [x] : [known(' '), x]))) : known('')
1189
+ else if (idx !== null && /^\d+$/.test(idx)) base = v.array[Number(idx)] ?? known('')
1190
+ else base = join(...v.array)
1191
+ } else if (v) {
1192
+ base = v.value
1193
+ if (v.unsetMaybe) unset = true
1194
+ } else {
1195
+ const ev = this.opts.envValues?.(p.name)
1196
+ if (ev) base = ev
1197
+ else if (INT_SHELL_VARS.has(p.name)) base = hole('int', `$${p.name}`)
1198
+ else {
1199
+ base = unknownString(`$${p.name}`)
1200
+ unset = true
1201
+ }
1202
+ }
1203
+ }
1204
+ const op = p.op
1205
+ if (!op) return base
1206
+ switch (op.kind) {
1207
+ case 'default':
1208
+ case 'assign': {
1209
+ const d = op.word ? this.stringValue(op.word, env, ctx) : known('')
1210
+ const c = concrete(base)
1211
+ let out: AValue
1212
+ if (c !== undefined && !unset && (c !== '' || !op.colon)) out = base
1213
+ else if (c === '' && op.colon && !unset) out = d
1214
+ else if (env.vars.has(p.name) && !unset && !op.colon) out = base
1215
+ else out = env.vars.has(p.name) || this.opts.envValues?.(p.name) ? join(base, d) : d
1216
+ if (op.kind === 'assign') env.vars.set(p.name, { value: out, exported: env.vars.get(p.name)?.exported ?? false })
1217
+ return out
1218
+ }
1219
+ case 'alternative': {
1220
+ const c = concrete(base)
1221
+ const alt = op.word ? this.stringValue(op.word, env, ctx) : known('')
1222
+ if (c !== undefined && !unset) return c === '' && op.colon ? known('') : alt
1223
+ return join(alt, known(''))
1224
+ }
1225
+ case 'error':
1226
+ return base
1227
+ case 'removePrefix':
1228
+ case 'removeSuffix':
1229
+ case 'replace': {
1230
+ const c = concrete(base)
1231
+ const pat = op.kind === 'replace' ? op.pattern : op.pattern
1232
+ const patText = this.patternText(pat, env, ctx)
1233
+ const patKnown = concrete(this.stringValue(pat, env, ctx)) !== undefined
1234
+ if (c !== undefined && patKnown) {
1235
+ if (op.kind === 'removePrefix') return known(removePrefix(c, patText, op.longest))
1236
+ if (op.kind === 'removeSuffix') return known(removeSuffix(c, patText, op.longest))
1237
+ if (op.kind === 'replace') {
1238
+ const rep = op.replacement ? concrete(this.stringValue(op.replacement, env, ctx)) : ''
1239
+ if (rep !== undefined) return known(replacePattern(c, patText, rep, op.mode))
1240
+ }
1241
+ }
1242
+ const origin = this.text(p.span)
1243
+ // `${p##*/}` and `${p%%/*}` never contain `/`; nor does `${p//\//_}`
1244
+ if (patKnown && op.kind !== 'replace' && op.longest && patText === (op.kind === 'removePrefix' ? '*/' : '/*')) return hole('segment', origin)
1245
+ if (op.kind === 'replace' && op.mode === 'all' && patKnown && (patText === '/' || patText === '\\/')) {
1246
+ const rep = op.replacement ? concrete(this.stringValue(op.replacement, env, ctx)) : ''
1247
+ if (rep !== undefined && !rep.includes('/')) return hole('segment', origin)
1248
+ }
1249
+ // removing part of a slash-free value keeps it slash-free (but it may become empty, `.` or `..`)
1250
+ const t = typeOf(base)
1251
+ if (t !== 'mixed' && t !== 'literal' && isSlashFree(t)) return hole(op.kind === 'replace' ? 'string' : 'segment', origin)
1252
+ // `${p%/*}` of a path is a path
1253
+ if (t === 'path') return hole('path', origin)
1254
+ return unknownString(origin)
1255
+ }
1256
+ case 'case': {
1257
+ const c = concrete(base)
1258
+ if (c !== undefined && !op.pattern) return known(op.upper ? (op.all ? c.toUpperCase() : c.slice(0, 1).toUpperCase() + c.slice(1)) : op.all ? c.toLowerCase() : c.slice(0, 1).toLowerCase() + c.slice(1))
1259
+ // case changes keep a slash-free value slash-free, and a name a name
1260
+ const t = typeOf(base)
1261
+ if (t !== 'mixed' && t !== 'literal' && isSlashFree(t)) return hole(isNameLike(t) ? 'identifier' : 'segment', this.text(p.span))
1262
+ return unknownString(this.text(p.span))
1263
+ }
1264
+ case 'substring': {
1265
+ const c = concrete(base)
1266
+ const off = concrete(this.arith(op.offset, env))
1267
+ const len = op.length ? concrete(this.arith(op.length, env)) : undefined
1268
+ if (c !== undefined && off !== undefined && (op.length === null || len !== undefined)) {
1269
+ let start = Number(off)
1270
+ if (start < 0) start = Math.max(0, c.length + start)
1271
+ const end = len === undefined ? c.length : Number(len) < 0 ? c.length + Number(len) : start + Number(len)
1272
+ return known(c.slice(start, end))
1273
+ }
1274
+ const t = typeOf(base)
1275
+ if (t !== 'mixed' && t !== 'literal' && isSlashFree(t)) return hole('segment', this.text(p.span))
1276
+ return unknownString(this.text(p.span))
1277
+ }
1278
+ case 'transform':
1279
+ return unknownString(this.text(p.span))
1280
+ }
1281
+ }
1282
+ }
1283
+
1284
+ import * as patternModule from '../vm/pattern.ts'
1285
+
1286
+ function readSmallFile(p: string): string | undefined {
1287
+ try {
1288
+ const st = fs.statSync(p)
1289
+ if (!st.isFile() || st.size > MAX_INLINE_BYTES) return undefined
1290
+ return fs.readFileSync(p, 'utf8')
1291
+ } catch {
1292
+ return undefined
1293
+ }
1294
+ }
1295
+
1296
+
1297
+ function hasSubstitution(w: A.Word): boolean {
1298
+ const visit = (parts: A.WordPart[]): boolean => parts.some((p) => p.type === 'CmdSubst' || p.type === 'ProcSubst' || (p.type === 'DblQuoted' && visit(p.parts)))
1299
+ return visit(w.parts)
1300
+ }
1301
+
1302
+ /** Commands whose recursive writes create the directory written (`git clone x`, `tar -C x -x`, `cp -r a x`). */
1303
+ const DIR_CREATORS = new Set(['git', 'tar', 'unzip', 'cp', 'rsync', 'install', 'mktemp', 'ditto', 'curl', 'wget'])
1304
+
1305
+ const NO_CTX: Ctx = { reach: 'may', loop: false, subshell: false, cmdsubst: false, pipeline: false, background: false, stdin: 'unknown', depth: 0 }
1306
+
1307
+ /** Variables bash itself sets to integers (when the snippet and environment do not define them). */
1308
+ const INT_SHELL_VARS = new Set(['RANDOM', 'SRANDOM', 'SECONDS', 'LINENO', 'BASHPID', 'PPID', 'UID', 'EUID', 'EPOCHSECONDS'])
1309
+
1310
+ const PREDICATES = new Set(['test', '[', 'true', 'false', 'which', 'type', 'hash', 'pgrep', 'id', 'cmp', 'diff', 'tty', 'getent', 'grep', 'egrep', 'fgrep', 'rg', 'command'])
1311
+
1312
+ /** Commands whose exit status is a yes/no answer: `x && y` after them is a guard, not a happy path. */
1313
+ function isPredicate(cmd: A.Command): boolean {
1314
+ switch (cmd.type) {
1315
+ case 'CondCommand':
1316
+ case 'ArithCommand':
1317
+ return true
1318
+ case 'Pipeline':
1319
+ return cmd.negated || isPredicate(cmd.stages[cmd.stages.length - 1])
1320
+ case 'AndOr':
1321
+ return isPredicate(cmd.items[cmd.items.length - 1])
1322
+ case 'SimpleCommand': {
1323
+ const w = cmd.words[0]
1324
+ const name = w && w.type === 'Word' ? literalOf(w) : null
1325
+ if (name === null || !PREDICATES.has(name)) return false
1326
+ const args = cmd.words.slice(1).map((x) => (x.type === 'Word' ? literalOf(x) ?? '' : ''))
1327
+ if (name === 'grep' || name === 'egrep' || name === 'fgrep' || name === 'rg') return args.some((a) => /^-[a-zA-Z]*q/.test(a) || a === '--quiet' || a === '--silent')
1328
+ if (name === 'command') return args.some((a) => a === '-v' || a === '-V')
1329
+ return true
1330
+ }
1331
+ default:
1332
+ return false
1333
+ }
1334
+ }
1335
+
1336
+ /** The simple command whose stdout is a command's stdout: itself, or the last stage of a pipeline. */
1337
+ function finalSimple(cmd: A.Command): A.SimpleCommand | undefined {
1338
+ if (cmd.type === 'SimpleCommand') return cmd
1339
+ if (cmd.type === 'Pipeline' && !cmd.negated) {
1340
+ const st = cmd.stages[cmd.stages.length - 1]
1341
+ return st.type === 'SimpleCommand' ? st : undefined
1342
+ }
1343
+ return undefined
1344
+ }
1345
+
1346
+ function isAllParam(p: A.ParamExp): boolean {
1347
+ if (p.length || p.op) return false
1348
+ if (p.name === '@' && !p.index) return true
1349
+ return p.index !== null && literalOf(p.index) === '@'
1350
+ }
1351
+
1352
+ /** `/a/b/../c` → `/a/c` for fully known paths (cwd tracking). */
1353
+ function normalizeKnownPath(v: AValue): AValue {
1354
+ const c = concrete(v)
1355
+ if (c === undefined || !c.startsWith('/')) return v
1356
+ const out: string[] = []
1357
+ for (const part of c.split('/')) {
1358
+ if (part === '' || part === '.') continue
1359
+ if (part === '..') out.pop()
1360
+ else out.push(part)
1361
+ }
1362
+ return known('/' + out.join('/'))
1363
+ }
1364
+
1365
+ /** Synchronous arithmetic for fully known operands (static analysis). */
1366
+ function syncArith(expr: A.ArithExpr, env: ArithEnv, ok: () => boolean): AValue | null {
1367
+ const words: A.ArithWord[] = []
1368
+ const visit = (e: A.ArithExpr): void => {
1369
+ switch (e.type) {
1370
+ case 'ArithWord': words.push(e); break
1371
+ case 'ArithVar': if (e.index) visit(e.index); break
1372
+ case 'ArithUnary': visit(e.operand); break
1373
+ case 'ArithBinary': visit(e.left); visit(e.right); break
1374
+ case 'ArithAssign': visit(e.value); break
1375
+ case 'ArithTernary': visit(e.cond); visit(e.then); visit(e.else); break
1376
+ case 'ArithGroup': visit(e.expr); break
1377
+ default: break
1378
+ }
1379
+ }
1380
+ visit(expr)
1381
+ if (words.length > 0) return null
1382
+ try {
1383
+ const { evaluateArithText } = arithModule
1384
+ const { arithText } = parserModule
1385
+ const text = arithText(expr)
1386
+ if (text === undefined) return null
1387
+ const v = evaluateArithText(text, env)
1388
+ return ok() ? known(String(v)) : null
1389
+ } catch {
1390
+ return null
1391
+ }
1392
+ }
1393
+
1394
+ import * as arithModule from '../vm/arith.ts'
1395
+ import * as parserModule from '../parser/parser.ts'