@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,121 @@
1
+ /**
2
+ * A simulated file system for static analysis: what the snippet has done to
3
+ * paths so far on the current control-flow path. It answers "is this a
4
+ * directory now?" for `cd` (and test refinements), on top of the real file
5
+ * system as it is when the analysis runs.
6
+ *
7
+ * Entries are concrete normalised absolute paths. A path without an entry
8
+ * takes its state from its nearest ancestor entry (a deleted or file ancestor
9
+ * makes it absent, an unknown one unknown), else from the disk. Writes and
10
+ * deletes whose path is not concrete are kept as `fuzzy` shapes: any path
11
+ * they may touch is `unknown`.
12
+ */
13
+
14
+ import * as fs from 'node:fs'
15
+ import type { PathShape } from '../policy/policy.ts'
16
+
17
+ export type FsState = 'dir' | 'file' | 'absent' | 'unknown'
18
+
19
+ const MAX_FUZZY = 16
20
+
21
+ export class AFs {
22
+ entries = new Map<string, FsState>()
23
+ /** Shapes of writes/deletes to paths not known statically. */
24
+ fuzzy: PathShape[] = []
25
+
26
+ clone(): AFs {
27
+ const f = new AFs()
28
+ f.entries = new Map(this.entries)
29
+ f.fuzzy = [...this.fuzzy]
30
+ return f
31
+ }
32
+
33
+ /**
34
+ * The state of `p`. `mayHaveCreated`: a command with unknown effects ran,
35
+ * so a path missing on disk may exist now.
36
+ */
37
+ lookup(p: string, mayHaveCreated = false): FsState {
38
+ const own = this.entries.get(p)
39
+ if (own !== undefined) return own
40
+ if (this.fuzzy.some((s) => touches(s, p))) return 'unknown'
41
+ for (let cur = parent(p); cur !== undefined; cur = parent(cur)) {
42
+ const st = this.entries.get(cur)
43
+ if (st === undefined) continue
44
+ if (st === 'absent' || st === 'file') return 'absent'
45
+ if (st === 'unknown') return 'unknown'
46
+ break // a known directory: the disk decides below it
47
+ }
48
+ const disk = diskState(p)
49
+ return disk === 'absent' && mayHaveCreated ? 'unknown' : disk
50
+ }
51
+
52
+ set(p: string, st: FsState): void {
53
+ this.entries.set(p, st)
54
+ }
55
+
56
+ /** `p` and its ancestors are directories (mkdir -p, or the parents of a written file). */
57
+ mkdirs(p: string): void {
58
+ for (let cur: string | undefined = p; cur !== undefined && cur !== '/'; cur = parent(cur)) {
59
+ if (this.entries.get(cur) === 'dir') break
60
+ this.entries.set(cur, 'dir')
61
+ }
62
+ }
63
+
64
+ /**
65
+ * `p` and everything below it are gone (`absent`: a failed `[ -e p ]`), or
66
+ * gone if the deleting command succeeded (`unknown`: `rm -rf p` may fail, and
67
+ * a definite verdict must not rely on it).
68
+ */
69
+ remove(p: string, st: 'absent' | 'unknown'): void {
70
+ const prefix = p === '/' ? '/' : p + '/'
71
+ for (const k of [...this.entries.keys()]) if (k.startsWith(prefix)) this.entries.delete(k)
72
+ this.entries.set(p, st)
73
+ }
74
+
75
+ /** A write or delete somewhere in `shapes`. */
76
+ fuzz(shapes: PathShape[]): void {
77
+ this.fuzzy.push(...shapes)
78
+ if (this.fuzzy.length > MAX_FUZZY) this.fuzzy = [{ kind: 'unknown', hint: 'many writes' }]
79
+ }
80
+
81
+ /** Control-flow merge: a path keeps its state only if both sides agree. */
82
+ static join(a: AFs, b: AFs, mayHaveCreated: boolean): AFs {
83
+ const out = new AFs()
84
+ for (const k of new Set([...a.entries.keys(), ...b.entries.keys()])) {
85
+ const x = a.lookup(k, mayHaveCreated)
86
+ const y = b.lookup(k, mayHaveCreated)
87
+ out.entries.set(k, x === y ? x : 'unknown')
88
+ }
89
+ out.fuzzy = [...a.fuzzy, ...b.fuzzy.filter((s) => !a.fuzzy.includes(s))]
90
+ if (out.fuzzy.length > MAX_FUZZY) out.fuzzy = [{ kind: 'unknown', hint: 'many writes' }]
91
+ return out
92
+ }
93
+ }
94
+
95
+ function parent(p: string): string | undefined {
96
+ if (p === '/') return undefined
97
+ const i = p.lastIndexOf('/')
98
+ return i <= 0 ? '/' : p.slice(0, i)
99
+ }
100
+
101
+ function inside(p: string, dir: string): boolean {
102
+ return p === dir || p.startsWith(dir === '/' ? '/' : dir + '/')
103
+ }
104
+
105
+ /** May a write/delete at `shape` change `p`? (A recursive delete of an ancestor removes it too.) */
106
+ function touches(shape: PathShape, p: string): boolean {
107
+ switch (shape.kind) {
108
+ case 'exact': return inside(p, shape.path)
109
+ case 'below': return inside(p, shape.dir)
110
+ case 'unknown': return true
111
+ }
112
+ }
113
+
114
+ function diskState(p: string): FsState {
115
+ try {
116
+ const st = fs.statSync(p)
117
+ return st.isDirectory() ? 'dir' : 'file'
118
+ } catch {
119
+ return 'absent'
120
+ }
121
+ }
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Output types of common commands for `$(...)`: how the analysis types a
3
+ * command substitution whose body is (or ends in) one simple command.
4
+ *
5
+ * Behavior definitions may declare `stdout` themselves (`mktemp`, `pwd`,
6
+ * `git rev-parse --show-toplevel`). This table refines or supplies the type
7
+ * for small pure commands whose output shape depends on their arguments
8
+ * (`date +%s` is an `int`, `tr / _` never prints a `/`). Every entry must be
9
+ * an over-approximation: the hole type claims a property of EVERY possible
10
+ * output (see the hole types in value.ts), because definite policy verdicts
11
+ * rely on it.
12
+ */
13
+
14
+ import type { Invocation, OutputInfo } from '../behaviors/types.ts'
15
+ import type { AValue, HoleType, Seg } from './value.ts'
16
+ import { concrete, isHole, isNameLike, isSlashFree, known } from './value.ts'
17
+
18
+ /** Refine the stdout type a behavior reported (or supply one). */
19
+ export function refineOutput(inv: Invocation, reported: OutputInfo | undefined): OutputInfo | undefined {
20
+ const args = inv.argv.slice(1)
21
+ const lit = args.map(concrete)
22
+ const t = (type: HoleType): OutputInfo => ({ type })
23
+ switch (inv.name) {
24
+ case 'date':
25
+ return dateOutput(args) ?? reported
26
+ case 'whoami':
27
+ case 'logname':
28
+ case 'arch':
29
+ case 'hostname':
30
+ // user and host names never contain `/` and are never `.`/`..`
31
+ return lit.every((a) => a !== undefined && /^-[a-zA-Z]+$/.test(a)) ? t('identifier') : reported
32
+ case 'nproc':
33
+ return lit.every((a) => a !== undefined && /^--?[a-z-]+$/.test(a)) ? t('int') : reported
34
+ case 'id':
35
+ if (lit.length >= 1 && lit.every((a) => a !== undefined && /^-[ugGnr]+$/.test(a))) {
36
+ const flags = lit.join('')
37
+ if (flags.includes('G')) return reported
38
+ return t(flags.includes('n') ? 'identifier' : 'int')
39
+ }
40
+ return reported
41
+ case 'uname':
42
+ // `-a`/`-v` include the kernel version string, which may contain `/`
43
+ return lit.every((a) => a !== undefined && /^-[smnrpio]+$/.test(a)) ? t('identifier') : reported
44
+ case 'uuidgen':
45
+ return args.length === 0 ? t('identifier') : reported
46
+ case 'basename':
47
+ return basenameOutput(args) ?? reported
48
+ case 'tr':
49
+ return trOutput(lit) ?? reported
50
+ case 'wc':
51
+ // counts only (no file names) when reading stdin: digits and blanks
52
+ return lit.every((a) => a !== undefined && /^-[clmw]+$/.test(a)) ? t('identifier') : reported
53
+ case 'git':
54
+ return gitOutput(lit) ?? reported
55
+ case 'md5':
56
+ // empty when the file is missing
57
+ return lit.includes('-q') && !lit.includes('-s') ? t('segment') : reported
58
+ case 'openssl':
59
+ return lit[0] === 'rand' && lit[1] === '-hex' && lit.length === 3 && /^\d+$/.test(lit[2] ?? '') ? t('hex') : reported
60
+ default:
61
+ return reported
62
+ }
63
+ }
64
+
65
+ // `%N` is nanoseconds in GNU date but a literal `N` in BSD date
66
+ const DIGIT_CONVERSIONS = 'YmdHMSsyjuwCGgVUW'
67
+ const NAME_CONVERSIONS = DIGIT_CONVERSIONS + 'NFTRzZaAbBhIp'
68
+
69
+ /** `date +FMT`: digits only → int; no whitespace or `/` → identifier. */
70
+ function dateOutput(args: AValue[]): OutputInfo | undefined {
71
+ const fmtArg = args.find((a) => (concrete(a) ?? '').startsWith('+'))
72
+ // any non-literal argument might be a format
73
+ if (args.some((a) => concrete(a) === undefined)) return undefined
74
+ if (!fmtArg) return undefined
75
+ const fmt = concrete(fmtArg)!.slice(1)
76
+ let digitsOnly = true
77
+ let anyConversion = false
78
+ for (let i = 0; i < fmt.length; i++) {
79
+ const ch = fmt[i]
80
+ if (ch === '%') {
81
+ let c = fmt[++i]
82
+ // flags and widths: %-d, %_H, %0e, %10s
83
+ while (c !== undefined && /[-_0^#]/.test(c)) c = fmt[++i]
84
+ while (c !== undefined && /\d/.test(c)) c = fmt[++i]
85
+ if (c === undefined) return undefined
86
+ if (c === '%') {
87
+ digitsOnly = false
88
+ continue
89
+ }
90
+ if (!NAME_CONVERSIONS.includes(c)) return undefined
91
+ if (!DIGIT_CONVERSIONS.includes(c)) digitsOnly = false
92
+ if (c !== 'Z' && c !== 'p') anyConversion = true
93
+ continue
94
+ }
95
+ if (/[\s/]/.test(ch)) return undefined
96
+ if (!/\d/.test(ch)) digitsOnly = false
97
+ }
98
+ if (!anyConversion) return undefined
99
+ return { type: digitsOnly ? 'int' : 'identifier' }
100
+ }
101
+
102
+ /**
103
+ * `basename NAME [SUFFIX]`: the last component of NAME. A concrete NAME
104
+ * gives the exact value; otherwise a real name when NAME's last component is
105
+ * one and no suffix is removed, else a `segment` (`basename ..` is `..`,
106
+ * `basename x.txt .txt`-style removal may leave anything).
107
+ */
108
+ function basenameOutput(args: AValue[]): OutputInfo | undefined {
109
+ const ops = args.filter((a) => !(concrete(a) ?? 'x').startsWith('-'))
110
+ if (ops.length !== args.length || ops.length === 0 || ops.length > 2) return { type: 'segment' }
111
+ const name = ops[0]
112
+ const suffix = ops[1] === undefined ? '' : concrete(ops[1])
113
+ const c = concrete(name)
114
+ if (c !== undefined && suffix !== undefined) return { type: 'segment', value: known(basenameOf(c, suffix)) }
115
+ if (ops.length === 1 && name.alts.every(lastComponentIsName)) return { type: 'identifier' }
116
+ return { type: 'segment' }
117
+ }
118
+
119
+ function basenameOf(s: string, suffix: string): string {
120
+ if (s === '') return ''
121
+ const t = s.replace(/\/+$/, '')
122
+ if (t === '') return '/'
123
+ let b = t.slice(t.lastIndexOf('/') + 1)
124
+ if (suffix && b !== suffix && b.endsWith(suffix)) b = b.slice(0, -suffix.length)
125
+ return b
126
+ }
127
+
128
+ /** The last component of every concretisation contains a name-like hole or a non-dot literal character (and no hole that may hold `/`). */
129
+ function lastComponentIsName(alt: Seg[]): boolean {
130
+ // strip trailing slashes in the final literal
131
+ const segs = [...alt]
132
+ while (segs.length > 0 && typeof segs[segs.length - 1] === 'string') {
133
+ const s = (segs[segs.length - 1] as string).replace(/\/+$/, '')
134
+ if (s === '') segs.pop()
135
+ else {
136
+ segs[segs.length - 1] = s
137
+ break
138
+ }
139
+ }
140
+ let named = false
141
+ for (let i = segs.length - 1; i >= 0; i--) {
142
+ const s = segs[i]
143
+ if (typeof s === 'string') {
144
+ const j = s.lastIndexOf('/')
145
+ const tail = j === -1 ? s : s.slice(j + 1)
146
+ if (/[^.]/.test(tail)) named = true
147
+ if (j !== -1) return named
148
+ continue
149
+ }
150
+ if (!isHole(s)) continue
151
+ if (isNameLike(s.type)) named = true
152
+ // a hole that may hold `/`: the last component starts somewhere inside it, after what we have seen
153
+ else if (!isSlashFree(s.type)) return named
154
+ }
155
+ return named
156
+ }
157
+
158
+ /** `tr` output without `/` (deleted, translated away, or not in the kept set). */
159
+ function trOutput(lit: (string | undefined)[]): OutputInfo | undefined {
160
+ if (lit.some((a) => a === undefined)) return undefined
161
+ const flags = new Set<string>()
162
+ const sets: string[] = []
163
+ for (const a of lit as string[]) {
164
+ if (sets.length === 0 && /^-[cdsC]+$/.test(a)) for (const f of a.slice(1)) flags.add(f === 'C' ? 'c' : f)
165
+ else sets.push(a)
166
+ }
167
+ const complement = flags.has('c')
168
+ const del = flags.has('d')
169
+ if (sets.length === 0 || sets.length > 2) return undefined
170
+ const s1 = expandTrSet(sets[0])
171
+ if (s1 === undefined) return undefined
172
+ if (del && sets.length === 1) {
173
+ // -d SET: removes SET; -dc SET: keeps only SET
174
+ if (complement ? !s1.includes('/') : s1.includes('/')) return { type: 'segment' }
175
+ return undefined
176
+ }
177
+ if (!del && !complement && sets.length === 2) {
178
+ const s2 = expandTrSet(sets[1])
179
+ if (s2 === undefined || s2.length === 0) return undefined
180
+ // every `/` in SET1 maps to a non-`/` character of SET2 (SET2 is padded with its last character)
181
+ const i = s1.indexOf('/')
182
+ if (i === -1) return undefined
183
+ for (let k = 0; k < s1.length; k++) if (s1[k] === '/' && (s2[Math.min(k, s2.length - 1)] ?? '/') === '/') return undefined
184
+ // (a `/` listed twice maps by its last occurrence in GNU tr and its first in BSD: all are checked)
185
+ return { type: 'segment' }
186
+ }
187
+ return undefined
188
+ }
189
+
190
+ /** Expand a `tr` set's ranges and escapes; undefined for classes and other constructs we do not model. */
191
+ function expandTrSet(s: string): string | undefined {
192
+ if (s.includes('[')) return undefined
193
+ const chars: string[] = []
194
+ for (let i = 0; i < s.length; i++) {
195
+ let c = s[i]
196
+ if (c === '\\') {
197
+ const n = s[++i]
198
+ if (n === undefined) return undefined
199
+ c = n === 'n' ? '\n' : n === 't' ? '\t' : n === '\\' ? '\\' : n
200
+ if (/[0-7]/.test(n)) return undefined
201
+ }
202
+ if (s[i + 1] === '-' && i + 2 < s.length) {
203
+ const end = s[i + 2]
204
+ if (end === '\\') return undefined
205
+ const a = c.charCodeAt(0)
206
+ const b = end.charCodeAt(0)
207
+ if (b < a) return undefined
208
+ for (let k = a; k <= b; k++) chars.push(String.fromCharCode(k))
209
+ i += 2
210
+ continue
211
+ }
212
+ chars.push(c)
213
+ }
214
+ return chars.join('')
215
+ }
216
+
217
+ /**
218
+ * `git rev-parse [--short] [--verify] REV`, `git rev-list --count`, `git log
219
+ * -1 --format=%H`: a hash or a count, but empty when git fails, and
220
+ * `rev-parse` echoes an unresolvable REV, so `segment` (and only when REV has
221
+ * no `/`).
222
+ */
223
+ function gitOutput(lit: (string | undefined)[]): OutputInfo | undefined {
224
+ if (lit.some((a) => a === undefined)) return undefined
225
+ const a = lit as string[]
226
+ const sub = a[0]
227
+ const rest = a.slice(1)
228
+ if (sub === 'rev-parse') {
229
+ const flags = rest.filter((x) => x.startsWith('-'))
230
+ const revs = rest.filter((x) => !x.startsWith('-'))
231
+ if (revs.length !== 1 || revs[0].includes('/')) return undefined
232
+ if (!flags.every((f) => f === '--verify' || f === '-q' || f === '--quiet' || f === '--short' || /^--short=\d+$/.test(f))) return undefined
233
+ return { type: 'segment' }
234
+ }
235
+ if (sub === 'rev-list' && rest.includes('--count')) return { type: 'segment' }
236
+ if (sub === 'log') {
237
+ const fmt = rest.find((x) => /^--(format|pretty)=/.test(x))
238
+ if (fmt && /^--(?:format|pretty)=(?:format:|tformat:)?%[hH]$/.test(fmt) && rest.every((x) => x === fmt || /^-(1|n1|-max-count=1)$/.test(x) || !x.startsWith('-'))) return { type: 'segment' }
239
+ return undefined
240
+ }
241
+ return undefined
242
+ }
@@ -0,0 +1,264 @@
1
+ /**
2
+ * Abstract string values for static analysis and behavior definitions.
3
+ *
4
+ * An {@link AValue} is a small set of alternatives; each alternative is a
5
+ * sequence of literal strings and typed {@link Hole}s (unknown substrings).
6
+ * Concrete runtime values are simply single-alternative, hole-free values,
7
+ * so behavior definitions are written once and serve both static analysis
8
+ * (abstract argv) and runtime enforcement (concrete argv).
9
+ *
10
+ * Hole types are a light-weight "type inference" for shell values:
11
+ * - `int`: decimal integer (never contains `/` or `.`)
12
+ * - `hex`: a non-empty hexadecimal string (commit ids, checksums)
13
+ * - `identifier`: a non-empty string without `/` that is not `.` or `..` (user and host names, formatted dates)
14
+ * - `filename`: a single existing path component (no `/`, never `.`/`..`), e.g. a glob match without directories
15
+ * - `segment`: a string without `/` that may be empty, `.` or `..` (the longest-prefix removal of `*` + `/` from a path, `tr / _` output)
16
+ * - `path`: a path, optionally known to be `under` a directory
17
+ * - `string`: anything
18
+ */
19
+
20
+ export type HoleType = 'string' | 'int' | 'hex' | 'identifier' | 'filename' | 'segment' | 'path'
21
+
22
+ /** Hole types that never contain `/`. */
23
+ export function isSlashFree(t: HoleType): boolean {
24
+ return t === 'int' || t === 'hex' || t === 'identifier' || t === 'filename' || t === 'segment'
25
+ }
26
+
27
+ /** Hole types that are never empty and never `.` or `..` (so a path component containing one is a real name). */
28
+ export function isNameLike(t: HoleType): boolean {
29
+ return t === 'int' || t === 'hex' || t === 'identifier' || t === 'filename'
30
+ }
31
+
32
+ export interface Hole {
33
+ hole: true
34
+ type: HoleType
35
+ /** Where the unknown came from, e.g. `$dir`, `$(git rev-parse --show-toplevel)`, `*.txt`. */
36
+ origin: string
37
+ /** For `path` holes: an absolute directory the value is known to lie under (after normalisation). */
38
+ under?: string
39
+ /** A glob pattern the value is known to match (for glob-derived holes). */
40
+ pattern?: string
41
+ }
42
+
43
+ export type Seg = string | Hole
44
+
45
+ export interface AValue {
46
+ /** Alternatives (at least one). */
47
+ alts: Seg[][]
48
+ }
49
+
50
+ export const MAX_ALTS = 16
51
+
52
+ export function isHole(s: Seg): s is Hole {
53
+ return typeof s !== 'string'
54
+ }
55
+
56
+ export function known(s: string): AValue {
57
+ return { alts: [[s]] }
58
+ }
59
+
60
+ export function hole(type: HoleType, origin: string, extra: Partial<Hole> = {}): AValue {
61
+ return { alts: [[{ hole: true, type, origin, ...extra }]] }
62
+ }
63
+
64
+ export function unknownString(origin: string): AValue {
65
+ return hole('string', origin)
66
+ }
67
+
68
+ function normalizeAlt(alt: Seg[]): Seg[] {
69
+ const out: Seg[] = []
70
+ for (const s of alt) {
71
+ if (typeof s === 'string') {
72
+ if (s === '') continue
73
+ const last = out[out.length - 1]
74
+ if (typeof last === 'string') out[out.length - 1] = last + s
75
+ else out.push(s)
76
+ } else out.push(s)
77
+ }
78
+ return out
79
+ }
80
+
81
+ function altKey(alt: Seg[]): string {
82
+ return JSON.stringify(alt)
83
+ }
84
+
85
+ function dedupe(alts: Seg[][]): Seg[][] {
86
+ const seen = new Set<string>()
87
+ const out: Seg[][] = []
88
+ for (const a of alts) {
89
+ const n = normalizeAlt(a)
90
+ const k = altKey(n)
91
+ if (!seen.has(k)) {
92
+ seen.add(k)
93
+ out.push(n)
94
+ }
95
+ }
96
+ return out
97
+ }
98
+
99
+ /** Collapse too many alternatives into a single unknown. */
100
+ function cap(alts: Seg[][], origin: string): AValue {
101
+ const d = dedupe(alts)
102
+ if (d.length <= MAX_ALTS) return { alts: d }
103
+ return { alts: [[{ hole: true, type: 'string', origin }]] }
104
+ }
105
+
106
+ export function concat(...values: AValue[]): AValue {
107
+ let alts: Seg[][] = [[]]
108
+ for (const v of values) {
109
+ const next: Seg[][] = []
110
+ for (const a of alts) for (const b of v.alts) next.push([...a, ...b])
111
+ alts = next
112
+ if (alts.length > MAX_ALTS * 4) return cap(alts, values.map(describe).join(''))
113
+ }
114
+ return cap(alts, values.map(describe).join(''))
115
+ }
116
+
117
+ /** Union of possibilities (control-flow join). */
118
+ export function join(...values: AValue[]): AValue {
119
+ return cap(values.flatMap((v) => v.alts), values.map(describe).join('|'))
120
+ }
121
+
122
+ /** The concrete string if the value is fully known with a single alternative. */
123
+ export function concrete(v: AValue): string | undefined {
124
+ if (v.alts.length !== 1) return undefined
125
+ const a = v.alts[0]
126
+ if (a.length === 0) return ''
127
+ if (a.length === 1 && typeof a[0] === 'string') return a[0]
128
+ return undefined
129
+ }
130
+
131
+ /** All alternatives are hole-free: the finite set of possible strings. */
132
+ export function finiteValues(v: AValue): string[] | undefined {
133
+ const out: string[] = []
134
+ for (const a of v.alts) {
135
+ if (a.some(isHole)) return undefined
136
+ out.push((a as string[]).join(''))
137
+ }
138
+ return out
139
+ }
140
+
141
+ export function isFullyKnown(v: AValue): boolean {
142
+ return v.alts.every((a) => !a.some(isHole))
143
+ }
144
+
145
+ /** The literal prefix shared by every alternative (possibly empty). */
146
+ export function knownPrefix(v: AValue): string {
147
+ const prefixes = v.alts.map((a) => (typeof a[0] === 'string' ? a[0] : ''))
148
+ if (prefixes.length === 0) return ''
149
+ let p = prefixes[0]
150
+ for (const q of prefixes.slice(1)) {
151
+ let i = 0
152
+ while (i < p.length && i < q.length && p[i] === q[i]) i++
153
+ p = p.slice(0, i)
154
+ }
155
+ return p
156
+ }
157
+
158
+ /** Human-readable rendering: holes shown as `⟨origin:type⟩`. */
159
+ export function describe(v: AValue): string {
160
+ const one = (a: Seg[]): string => a.map((s) => (typeof s === 'string' ? s : `⟨${s.origin}${s.type === 'string' ? '' : ':' + s.type}⟩`)).join('')
161
+ if (v.alts.length === 1) return one(v.alts[0])
162
+ return '{' + v.alts.map(one).join(' | ') + '}'
163
+ }
164
+
165
+ /** Map every alternative through a function of its segments. */
166
+ export function mapAlts(v: AValue, f: (alt: Seg[]) => Seg[]): AValue {
167
+ return cap(v.alts.map(f), describe(v))
168
+ }
169
+
170
+ /** A regex (source) matching every concretisation of one alternative. */
171
+ export function altRegexSource(alt: Seg[]): string {
172
+ return alt
173
+ .map((s) => {
174
+ if (typeof s === 'string') return s.replace(/[\\^$.*+?()[\]{}|/]/g, (c) => '\\' + c)
175
+ switch (s.type) {
176
+ case 'int': return '-?[0-9]+'
177
+ case 'hex': return '[0-9a-fA-F]+'
178
+ case 'identifier':
179
+ case 'filename': return '[^/]+'
180
+ case 'segment': return '[^/]*'
181
+ default: return '[\\s\\S]*'
182
+ }
183
+ })
184
+ .join('')
185
+ }
186
+
187
+ /** Can the value possibly equal `s`? */
188
+ export function mayEqual(v: AValue, s: string): boolean {
189
+ return v.alts.some((a) => new RegExp('^' + altRegexSource(a) + '$').test(s))
190
+ }
191
+
192
+ /** Can the value possibly start with `prefix`? (conservative: true unless provably not) */
193
+ export function mayStartWith(v: AValue, prefix: string): boolean {
194
+ return v.alts.some((a) => {
195
+ let rest = prefix
196
+ for (const s of a) {
197
+ if (rest === '') return true
198
+ if (typeof s === 'string') {
199
+ const n = Math.min(s.length, rest.length)
200
+ if (s.slice(0, n) !== rest.slice(0, n)) return false
201
+ rest = rest.slice(n)
202
+ } else {
203
+ return true
204
+ }
205
+ }
206
+ return rest === ''
207
+ })
208
+ }
209
+
210
+ /** Does every concretisation start with `prefix`? */
211
+ export function mustStartWith(v: AValue, prefix: string): boolean {
212
+ return v.alts.every((a) => typeof a[0] === 'string' && a[0].startsWith(prefix)) || (prefix === '')
213
+ }
214
+
215
+ /** Split an abstract value at the first occurrence of a literal separator in its literal part (per alternative), for `--opt=value` parsing. */
216
+ export function splitOnce(v: AValue, sep: string): [AValue, AValue] | undefined {
217
+ const lefts: Seg[][] = []
218
+ const rights: Seg[][] = []
219
+ for (const a of v.alts) {
220
+ let found = false
221
+ const left: Seg[] = []
222
+ const right: Seg[] = []
223
+ for (const s of a) {
224
+ if (found) right.push(s)
225
+ else if (typeof s === 'string' && s.includes(sep)) {
226
+ const i = s.indexOf(sep)
227
+ left.push(s.slice(0, i))
228
+ right.push(s.slice(i + sep.length))
229
+ found = true
230
+ } else if (typeof s !== 'string') {
231
+ return undefined
232
+ } else left.push(s)
233
+ }
234
+ if (!found) return undefined
235
+ lefts.push(left)
236
+ rights.push(right)
237
+ }
238
+ return [cap(lefts, 'left'), cap(rights, 'right')]
239
+ }
240
+
241
+ /**
242
+ * A whole argument that may be the empty string (only `segment` holes, no
243
+ * literal text) is downgraded to `string`: `touch "$x"` with an empty `$x`
244
+ * touches nothing, so the resolved path (the cwd) is not definitely written.
245
+ */
246
+ export function guardEmpty(v: AValue): AValue {
247
+ let changed = false
248
+ const alts = v.alts.map((a) => {
249
+ if (!a.every((s) => isHole(s) && !isNameLike(s.type) && s.type !== 'path')) return a
250
+ if (!a.some((s) => isHole(s) && s.type === 'segment')) return a
251
+ changed = true
252
+ return a.map((s): Seg => (isHole(s) && s.type === 'segment' ? { ...s, type: 'string' } : s))
253
+ })
254
+ return changed ? { alts } : v
255
+ }
256
+
257
+ /** Lines/fields of a value: concrete values split; unknown values become one unknown element of the same type. */
258
+ export function typeOf(v: AValue): HoleType | 'literal' | 'mixed' {
259
+ const holes = v.alts.flatMap((a) => a.filter(isHole))
260
+ if (holes.length === 0) return 'literal'
261
+ const types = new Set(holes.map((h) => h.type))
262
+ if (types.size === 1 && v.alts.every((a) => a.length === 1)) return holes[0].type
263
+ return 'mixed'
264
+ }