@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.
- package/LICENSE +21 -0
- package/README.md +275 -2
- package/bin/mash.ts +253 -0
- package/dist/bin/mash.d.ts +39 -0
- package/dist/bin/mash.d.ts.map +1 -0
- package/dist/bin/mash.js +281 -0
- package/dist/bin/mash.js.map +1 -0
- package/dist/src/analysis/analyze.d.ts +97 -0
- package/dist/src/analysis/analyze.d.ts.map +1 -0
- package/dist/src/analysis/analyze.js +1406 -0
- package/dist/src/analysis/analyze.js.map +1 -0
- package/dist/src/analysis/fs.d.ts +39 -0
- package/dist/src/analysis/fs.d.ts.map +1 -0
- package/dist/src/analysis/fs.js +117 -0
- package/dist/src/analysis/fs.js.map +1 -0
- package/dist/src/analysis/outputs.d.ts +16 -0
- package/dist/src/analysis/outputs.d.ts.map +1 -0
- package/dist/src/analysis/outputs.js +277 -0
- package/dist/src/analysis/outputs.js.map +1 -0
- package/dist/src/analysis/value.d.ts +76 -0
- package/dist/src/analysis/value.d.ts.map +1 -0
- package/dist/src/analysis/value.js +248 -0
- package/dist/src/analysis/value.js.map +1 -0
- package/dist/src/behaviors/defs/core.d.ts +15 -0
- package/dist/src/behaviors/defs/core.d.ts.map +1 -0
- package/dist/src/behaviors/defs/core.js +1084 -0
- package/dist/src/behaviors/defs/core.js.map +1 -0
- package/dist/src/behaviors/defs/devtools.d.ts +9 -0
- package/dist/src/behaviors/defs/devtools.d.ts.map +1 -0
- package/dist/src/behaviors/defs/devtools.js +432 -0
- package/dist/src/behaviors/defs/devtools.js.map +1 -0
- package/dist/src/behaviors/defs/macos.d.ts +12 -0
- package/dist/src/behaviors/defs/macos.d.ts.map +1 -0
- package/dist/src/behaviors/defs/macos.js +454 -0
- package/dist/src/behaviors/defs/macos.js.map +1 -0
- package/dist/src/behaviors/defs/tools.d.ts +9 -0
- package/dist/src/behaviors/defs/tools.d.ts.map +1 -0
- package/dist/src/behaviors/defs/tools.js +639 -0
- package/dist/src/behaviors/defs/tools.js.map +1 -0
- package/dist/src/behaviors/dsl.d.ts +95 -0
- package/dist/src/behaviors/dsl.d.ts.map +1 -0
- package/dist/src/behaviors/dsl.js +267 -0
- package/dist/src/behaviors/dsl.js.map +1 -0
- package/dist/src/behaviors/index.d.ts +12 -0
- package/dist/src/behaviors/index.d.ts.map +1 -0
- package/dist/src/behaviors/index.js +18 -0
- package/dist/src/behaviors/index.js.map +1 -0
- package/dist/src/behaviors/registry.d.ts +45 -0
- package/dist/src/behaviors/registry.d.ts.map +1 -0
- package/dist/src/behaviors/registry.js +266 -0
- package/dist/src/behaviors/registry.js.map +1 -0
- package/dist/src/behaviors/roles.d.ts +29 -0
- package/dist/src/behaviors/roles.d.ts.map +1 -0
- package/dist/src/behaviors/roles.js +198 -0
- package/dist/src/behaviors/roles.js.map +1 -0
- package/dist/src/behaviors/types.d.ts +120 -0
- package/dist/src/behaviors/types.d.ts.map +1 -0
- package/dist/src/behaviors/types.js +12 -0
- package/dist/src/behaviors/types.js.map +1 -0
- package/dist/src/index.d.ts +27 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +24 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/integrations/deepsea.d.ts +121 -0
- package/dist/src/integrations/deepsea.d.ts.map +1 -0
- package/dist/src/integrations/deepsea.js +199 -0
- package/dist/src/integrations/deepsea.js.map +1 -0
- package/dist/src/machine.d.ts +139 -0
- package/dist/src/machine.d.ts.map +1 -0
- package/dist/src/machine.js +400 -0
- package/dist/src/machine.js.map +1 -0
- package/dist/src/parser/ast.d.ts +369 -0
- package/dist/src/parser/ast.d.ts.map +1 -0
- package/dist/src/parser/ast.js +17 -0
- package/dist/src/parser/ast.js.map +1 -0
- package/dist/src/parser/parser.d.ts +48 -0
- package/dist/src/parser/parser.d.ts.map +1 -0
- package/dist/src/parser/parser.js +1950 -0
- package/dist/src/parser/parser.js.map +1 -0
- package/dist/src/policy/effects.d.ts +86 -0
- package/dist/src/policy/effects.d.ts.map +1 -0
- package/dist/src/policy/effects.js +53 -0
- package/dist/src/policy/effects.js.map +1 -0
- package/dist/src/policy/policy.d.ts +130 -0
- package/dist/src/policy/policy.d.ts.map +1 -0
- package/dist/src/policy/policy.js +532 -0
- package/dist/src/policy/policy.js.map +1 -0
- package/dist/src/printer/doc.d.ts +151 -0
- package/dist/src/printer/doc.d.ts.map +1 -0
- package/dist/src/printer/doc.js +409 -0
- package/dist/src/printer/doc.js.map +1 -0
- package/dist/src/printer/highlight.d.ts +53 -0
- package/dist/src/printer/highlight.d.ts.map +1 -0
- package/dist/src/printer/highlight.js +339 -0
- package/dist/src/printer/highlight.js.map +1 -0
- package/dist/src/printer/html.d.ts +72 -0
- package/dist/src/printer/html.d.ts.map +1 -0
- package/dist/src/printer/html.js +423 -0
- package/dist/src/printer/html.js.map +1 -0
- package/dist/src/printer/print.d.ts +63 -0
- package/dist/src/printer/print.d.ts.map +1 -0
- package/dist/src/printer/print.js +484 -0
- package/dist/src/printer/print.js.map +1 -0
- package/dist/src/printer/summary.d.ts +50 -0
- package/dist/src/printer/summary.d.ts.map +1 -0
- package/dist/src/printer/summary.js +621 -0
- package/dist/src/printer/summary.js.map +1 -0
- package/dist/src/trace/events.d.ts +183 -0
- package/dist/src/trace/events.d.ts.map +1 -0
- package/dist/src/trace/events.js +9 -0
- package/dist/src/trace/events.js.map +1 -0
- package/dist/src/trace/progress.d.ts +58 -0
- package/dist/src/trace/progress.d.ts.map +1 -0
- package/dist/src/trace/progress.js +83 -0
- package/dist/src/trace/progress.js.map +1 -0
- package/dist/src/vm/arith.d.ts +29 -0
- package/dist/src/vm/arith.d.ts.map +1 -0
- package/dist/src/vm/arith.js +258 -0
- package/dist/src/vm/arith.js.map +1 -0
- package/dist/src/vm/builtins.d.ts +31 -0
- package/dist/src/vm/builtins.d.ts.map +1 -0
- package/dist/src/vm/builtins.js +1773 -0
- package/dist/src/vm/builtins.js.map +1 -0
- package/dist/src/vm/emulate.d.ts +75 -0
- package/dist/src/vm/emulate.d.ts.map +1 -0
- package/dist/src/vm/emulate.js +380 -0
- package/dist/src/vm/emulate.js.map +1 -0
- package/dist/src/vm/expand.d.ts +68 -0
- package/dist/src/vm/expand.d.ts.map +1 -0
- package/dist/src/vm/expand.js +744 -0
- package/dist/src/vm/expand.js.map +1 -0
- package/dist/src/vm/external.d.ts +34 -0
- package/dist/src/vm/external.d.ts.map +1 -0
- package/dist/src/vm/external.js +228 -0
- package/dist/src/vm/external.js.map +1 -0
- package/dist/src/vm/fifo.d.ts +66 -0
- package/dist/src/vm/fifo.d.ts.map +1 -0
- package/dist/src/vm/fifo.js +232 -0
- package/dist/src/vm/fifo.js.map +1 -0
- package/dist/src/vm/interp.d.ts +209 -0
- package/dist/src/vm/interp.d.ts.map +1 -0
- package/dist/src/vm/interp.js +1641 -0
- package/dist/src/vm/interp.js.map +1 -0
- package/dist/src/vm/io.d.ts +180 -0
- package/dist/src/vm/io.d.ts.map +1 -0
- package/dist/src/vm/io.js +502 -0
- package/dist/src/vm/io.js.map +1 -0
- package/dist/src/vm/pattern.d.ts +46 -0
- package/dist/src/vm/pattern.d.ts.map +1 -0
- package/dist/src/vm/pattern.js +498 -0
- package/dist/src/vm/pattern.js.map +1 -0
- package/dist/src/vm/proc.d.ts +94 -0
- package/dist/src/vm/proc.d.ts.map +1 -0
- package/dist/src/vm/proc.js +130 -0
- package/dist/src/vm/proc.js.map +1 -0
- package/dist/src/vm/state.d.ts +104 -0
- package/dist/src/vm/state.d.ts.map +1 -0
- package/dist/src/vm/state.js +233 -0
- package/dist/src/vm/state.js.map +1 -0
- package/dist/src/vm/test.d.ts +11 -0
- package/dist/src/vm/test.d.ts.map +1 -0
- package/dist/src/vm/test.js +277 -0
- package/dist/src/vm/test.js.map +1 -0
- package/package.json +63 -4
- package/src/analysis/analyze.ts +1395 -0
- package/src/analysis/fs.ts +121 -0
- package/src/analysis/outputs.ts +242 -0
- package/src/analysis/value.ts +264 -0
- package/src/behaviors/defs/core.ts +970 -0
- package/src/behaviors/defs/devtools.ts +400 -0
- package/src/behaviors/defs/macos.ts +426 -0
- package/src/behaviors/defs/tools.ts +596 -0
- package/src/behaviors/dsl.ts +293 -0
- package/src/behaviors/index.ts +19 -0
- package/src/behaviors/registry.ts +261 -0
- package/src/behaviors/roles.ts +187 -0
- package/src/behaviors/types.ts +127 -0
- package/src/index.ts +27 -0
- package/src/integrations/deepsea.ts +259 -0
- package/src/machine.ts +483 -0
- package/src/parser/ast.ts +472 -0
- package/src/parser/parser.ts +1826 -0
- package/src/policy/effects.ts +102 -0
- package/src/policy/policy.ts +578 -0
- package/src/printer/doc.ts +454 -0
- package/src/printer/highlight.ts +283 -0
- package/src/printer/html.ts +441 -0
- package/src/printer/print.ts +528 -0
- package/src/printer/summary.ts +601 -0
- package/src/trace/events.ts +102 -0
- package/src/trace/progress.ts +119 -0
- package/src/vm/arith.ts +236 -0
- package/src/vm/builtins.ts +1590 -0
- package/src/vm/emulate.ts +337 -0
- package/src/vm/expand.ts +721 -0
- package/src/vm/external.ts +265 -0
- package/src/vm/fifo.ts +226 -0
- package/src/vm/interp.ts +1623 -0
- package/src/vm/io.ts +570 -0
- package/src/vm/pattern.ts +477 -0
- package/src/vm/proc.ts +147 -0
- package/src/vm/state.ts +267 -0
- 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
|
+
}
|