omakit 0.4.2 → 0.4.3
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/README.md +9 -3
- package/package.json +1 -1
- package/skills/omarchy-plugin-check/SKILL.md +57 -1
- package/tools/inspect/contract.mjs +245 -0
- package/tools/inspect/functions.mjs +129 -0
- package/tools/inspect/hosts.mjs +141 -0
- package/tools/inspect/inspect.mjs +170 -0
- package/tools/inspect/patterns.mjs +326 -0
- package/tools/inspect/processes.mjs +298 -0
- package/tools/inspect/report.mjs +342 -0
- package/tools/inspect/text.mjs +496 -0
- package/tools/inspect/timers.mjs +65 -0
- package/tools/inspect/walk.mjs +102 -0
- package/tools/inspect/writes.mjs +194 -0
- package/tools/marketplace/README.md +20 -1
- package/tools/marketplace/cli.mjs +48 -0
- package/tools/marketplace/local-transport.mjs +16 -4
- package/tools/marketplace/options.mjs +1 -0
- package/tools/marketplace/pin.mjs +18 -10
- package/tools/marketplace/run-baseline.mjs +2 -1
- package/tools/marketplace/style.mjs +3 -0
- package/tools/marketplace/usage.mjs +21 -0
- package/tools/marketplace/verify.mjs +15 -4
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
// Process sites: every `Process {` block in QML (its `command:` inside the
|
|
2
|
+
// block or assigned to its id elsewhere in the file), every
|
|
3
|
+
// `Quickshell.execDetached([...])`, and every command line of a shell
|
|
4
|
+
// script. Each row carries the argv the text shows, whether a deadline is
|
|
5
|
+
// observed for it, what collects its output and whether a producer-side
|
|
6
|
+
// cap is observed in the argv. A command that is not one literal array or
|
|
7
|
+
// string is `argvForm: "computed"` with `argv: null`, never a guess.
|
|
8
|
+
|
|
9
|
+
import { arrayLiteral, basename, blankComments, blocks, closingBracket, lineOf, propertyValue, blankShellExpressions, shellLogicalLines, shellPieces, shellWords, stringLiteral, withoutRedirections } from "./text.mjs"
|
|
10
|
+
|
|
11
|
+
/** Shells whose `-c` turns the next element into a script the extraction does not follow. */
|
|
12
|
+
const SHELLS = new Set(["sh", "bash", "zsh", "dash", "fish", "ksh"])
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Wrappers that run the word after them: the tool a host or a pattern is
|
|
16
|
+
* attributed to is the first argv word that is not one of these, with the
|
|
17
|
+
* wrapper's own options skipped. `timeout` is also the deadline the argv
|
|
18
|
+
* carries.
|
|
19
|
+
*/
|
|
20
|
+
const WRAPPERS = new Set(["sudo", "doas", "pkexec", "env", "nohup", "nice", "ionice", "command", "exec", "timeout", "stdbuf", "chronic"])
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Shell builtins and keywords that start no process. A line whose command
|
|
24
|
+
* is one of these is not a process site; `eval` is kept because it is the
|
|
25
|
+
* one builtin that runs computed text.
|
|
26
|
+
*/
|
|
27
|
+
const BUILTINS = new Set([
|
|
28
|
+
"cd", "set", "export", "local", "declare", "readonly", "typeset", "unset", "shift", "return", "exit", "break", "continue",
|
|
29
|
+
"echo", "printf", "read", "source", ".", "trap", "true", "false", ":", "test", "[", "[[", "wait", "umask", "alias", "unalias",
|
|
30
|
+
"pushd", "popd", "let", "type", "hash", "builtin", "getopts", "ulimit", "times", "jobs", "fg", "bg", "kill", "help", "exec",
|
|
31
|
+
])
|
|
32
|
+
const KEYWORDS = new Set(["if", "then", "else", "elif", "fi", "do", "done", "while", "until", "for", "in", "case", "esac", "select", "function", "time", "!", "{", "}", "(", ")", "coproc"])
|
|
33
|
+
|
|
34
|
+
/** The first argv word that is a tool, wrappers and their options skipped, with its index. */
|
|
35
|
+
export function toolOf(argv) {
|
|
36
|
+
let index = 0
|
|
37
|
+
while (index < argv.length) {
|
|
38
|
+
const word = basename(argv[index])
|
|
39
|
+
if (!WRAPPERS.has(word)) break
|
|
40
|
+
index += 1
|
|
41
|
+
if (word === "env") while (index < argv.length && (/^[A-Za-z_]\w*=/.test(argv[index]) || argv[index].startsWith("-"))) index += 1
|
|
42
|
+
else if (word === "timeout") {
|
|
43
|
+
while (index < argv.length && argv[index].startsWith("-")) index += /^(?:-s|-k|--signal|--kill-after)$/.test(argv[index]) ? 2 : 1
|
|
44
|
+
if (index < argv.length && /^\d/.test(argv[index])) index += 1
|
|
45
|
+
} else while (index < argv.length && argv[index].startsWith("-")) index += 1
|
|
46
|
+
}
|
|
47
|
+
return index < argv.length ? { tool: argv[index], index } : { tool: null, index: -1 }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The `timeout` duration in an argv, in milliseconds, or null: `timeout 5`, `timeout 2m`, `timeout -k 1 5s`. */
|
|
51
|
+
export function timeoutMs(argv) {
|
|
52
|
+
const at = argv.findIndex((word) => basename(word) === "timeout")
|
|
53
|
+
if (at < 0) return null
|
|
54
|
+
let index = at + 1
|
|
55
|
+
while (index < argv.length && argv[index].startsWith("-")) index += /^(?:-s|-k|--signal|--kill-after)$/.test(argv[index]) ? 2 : 1
|
|
56
|
+
const match = String(argv[index] || "").match(/^(\d+(?:\.\d+)?)([smhd]?)$/)
|
|
57
|
+
if (!match) return null
|
|
58
|
+
const unit = { "": 1000, s: 1000, m: 60000, h: 3600000, d: 86400000 }[match[2]]
|
|
59
|
+
return Math.round(Number(match[1]) * unit)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Whether a joined argv shows a producer-side cap, and which. */
|
|
63
|
+
function capIn(argv) {
|
|
64
|
+
if (!argv) return { observed: false, via: null }
|
|
65
|
+
const joined = argv.join(" ")
|
|
66
|
+
if (argv.some((word) => word.startsWith("--max-filesize"))) return { observed: true, via: "--max-filesize" }
|
|
67
|
+
if (/(?:^|[\s|/])head\s+(?:-\S+\s+)*-c\b/.test(joined) || argv.some((word, index) => basename(word) === "head" && argv[index + 1] === "-c")) return { observed: true, via: "head -c" }
|
|
68
|
+
if (argv.some((word) => basename(word) === "timeout")) return { observed: true, via: "timeout" }
|
|
69
|
+
return { observed: false, via: null }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function isShellWrapper(argv) {
|
|
73
|
+
if (!argv || argv.length < 2) return false
|
|
74
|
+
const { tool, index } = toolOf(argv)
|
|
75
|
+
if (!tool) return false
|
|
76
|
+
if (basename(tool) === "eval") return true
|
|
77
|
+
// `-c` alone or inside a cluster: `sh -lc`, `bash -ec`.
|
|
78
|
+
return SHELLS.has(basename(tool)) && /^-[a-z]*c[a-z]*$/.test(argv[index + 1] || "")
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The argv an expression resolves to, or the computed form. */
|
|
82
|
+
function resolveCommand(valueText) {
|
|
83
|
+
const elements = arrayLiteral(valueText)
|
|
84
|
+
if (elements) {
|
|
85
|
+
const expressions = elements.map((element, index) => (element.literal === null ? { index, text: element.text } : null)).filter(Boolean)
|
|
86
|
+
return { argv: elements.map((element) => element.literal ?? element.text), argvForm: "array", expressions }
|
|
87
|
+
}
|
|
88
|
+
const literal = stringLiteral(valueText)
|
|
89
|
+
if (literal !== null) {
|
|
90
|
+
const words = shellWords(literal)
|
|
91
|
+
return { argv: words, argvForm: "string", expressions: [] }
|
|
92
|
+
}
|
|
93
|
+
return { argv: null, argvForm: "computed", expressions: [], text: valueText.trim() }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The value assigned to `<id>.command =` somewhere in the text, with its offset. */
|
|
97
|
+
function assignedCommand(text, id) {
|
|
98
|
+
if (!id) return null
|
|
99
|
+
const pattern = new RegExp(`(?<![\\w.])${id}\\.command\\s*=(?!=)\\s*`, "g")
|
|
100
|
+
const match = pattern.exec(text)
|
|
101
|
+
if (!match) return null
|
|
102
|
+
const start = match.index + match[0].length
|
|
103
|
+
if (text[start] === "[") {
|
|
104
|
+
const end = closingBracket(text, start)
|
|
105
|
+
if (end > 0) return { text: text.slice(start, end + 1), offset: match.index }
|
|
106
|
+
}
|
|
107
|
+
let end = text.indexOf("\n", start)
|
|
108
|
+
if (end < 0) end = text.length
|
|
109
|
+
return { text: text.slice(start, end).replace(/;\s*$/, ""), offset: match.index }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Whether a handler body stops the process with the given id. */
|
|
113
|
+
function stops(body, id) {
|
|
114
|
+
if (!id) return false
|
|
115
|
+
return new RegExp(`(?<![\\w.])${id}\\.(?:kill\\s*\\(|signal\\s*\\(|running\\s*=\\s*false)`).test(body)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function numeric(valueText) {
|
|
119
|
+
return valueText !== undefined && valueText !== null && /^\d+$/.test(String(valueText).trim()) ? Number(valueText) : null
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The deadline the file shows for a process: a killing Timer, `timeout` in argv, or a destruction handler. */
|
|
123
|
+
function deadlineFor(text, id, argv) {
|
|
124
|
+
for (const timer of blocks(text, "Timer")) {
|
|
125
|
+
const handler = propertyValue(timer.body, "onTriggered")
|
|
126
|
+
if (handler && stops(handler.text, id)) {
|
|
127
|
+
return { observed: true, via: "timer-kill", ms: numeric(propertyValue(timer.body, "interval")?.text) }
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const ms = argv ? timeoutMs(argv) : null
|
|
131
|
+
if (argv && argv.some((word) => basename(word) === "timeout")) return { observed: true, via: "timeout-argv", ms }
|
|
132
|
+
const destruction = text.match(/Component\.onDestruction\s*:/)
|
|
133
|
+
if (destruction) {
|
|
134
|
+
const start = destruction.index + destruction[0].length
|
|
135
|
+
const trimmed = text.slice(start).replace(/^\s+/, "")
|
|
136
|
+
const at = start + (text.slice(start).length - trimmed.length)
|
|
137
|
+
const end = text[at] === "{" ? closingBracket(text, at) : text.indexOf("\n", at)
|
|
138
|
+
if (end > 0 && stops(text.slice(at, end + 1), id)) return { observed: true, via: "destruction", ms: null }
|
|
139
|
+
}
|
|
140
|
+
return { observed: false, via: null, ms: null }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function outputOf(body, argv) {
|
|
144
|
+
const collector = /(?<![\w.])StdioCollector\s*\{/.test(body) ? "StdioCollector"
|
|
145
|
+
: /(?<![\w.])SplitParser\s*\{/.test(body) ? "SplitParser"
|
|
146
|
+
: propertyValue(body, "stdout") ? "unknown" : "none"
|
|
147
|
+
const cap = capIn(argv)
|
|
148
|
+
return { collector, capObserved: cap.observed, via: cap.via }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function qmlRow(file, text, block, command, commandOffset) {
|
|
152
|
+
const resolved = command ? resolveCommand(command.text) : { argv: null, argvForm: "computed", expressions: [], text: null }
|
|
153
|
+
const id = block.id
|
|
154
|
+
const running = Boolean(propertyValue(block.body, "running")?.text === "true"
|
|
155
|
+
|| (id && new RegExp(`(?<![\\w.])${id}\\.(?:running\\s*=\\s*true|start\\s*\\(|startDetached\\s*\\()`).test(text)))
|
|
156
|
+
return {
|
|
157
|
+
file: file.path,
|
|
158
|
+
line: lineOf(text, commandOffset ?? block.start),
|
|
159
|
+
declaredIn: "qml",
|
|
160
|
+
id,
|
|
161
|
+
argv: resolved.argv,
|
|
162
|
+
argvForm: resolved.argvForm,
|
|
163
|
+
expressions: resolved.expressions,
|
|
164
|
+
commandText: resolved.argvForm === "computed" ? (resolved.text || "command not declared in the block") : null,
|
|
165
|
+
running,
|
|
166
|
+
detached: false,
|
|
167
|
+
deadline: deadlineFor(text, id, resolved.argv),
|
|
168
|
+
output: outputOf(block.body, resolved.argv),
|
|
169
|
+
shellWrapper: isShellWrapper(resolved.argv),
|
|
170
|
+
pipedFrom: null,
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function detachedRows(file, text) {
|
|
175
|
+
const rows = []
|
|
176
|
+
for (const match of text.matchAll(/(?<![\w.])(?:Quickshell\.execDetached|Process\.exec)\s*\(/g)) {
|
|
177
|
+
const open = match.index + match[0].length - 1
|
|
178
|
+
const end = closingBracket(text, open)
|
|
179
|
+
if (end < 0) continue
|
|
180
|
+
const inner = text.slice(open + 1, end).trim()
|
|
181
|
+
// execDetached takes an argv array, or an object whose `command` is one.
|
|
182
|
+
const value = inner.startsWith("{") ? propertyValue(inner.slice(1, -1), "command")?.text || inner : inner
|
|
183
|
+
const resolved = resolveCommand(value)
|
|
184
|
+
rows.push({
|
|
185
|
+
file: file.path,
|
|
186
|
+
line: lineOf(text, match.index),
|
|
187
|
+
declaredIn: "qml",
|
|
188
|
+
id: null,
|
|
189
|
+
argv: resolved.argv,
|
|
190
|
+
argvForm: resolved.argvForm,
|
|
191
|
+
expressions: resolved.expressions,
|
|
192
|
+
commandText: resolved.argvForm === "computed" ? resolved.text : null,
|
|
193
|
+
running: true,
|
|
194
|
+
detached: true,
|
|
195
|
+
deadline: { observed: false, via: null, ms: null },
|
|
196
|
+
output: { collector: "none", capObserved: false, via: null },
|
|
197
|
+
shellWrapper: isShellWrapper(resolved.argv),
|
|
198
|
+
pipedFrom: null,
|
|
199
|
+
})
|
|
200
|
+
}
|
|
201
|
+
return rows
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Every `Process {` block and detached exec in a QML file. */
|
|
205
|
+
export function qmlProcesses(file) {
|
|
206
|
+
const text = blankComments(file.text)
|
|
207
|
+
const rows = []
|
|
208
|
+
for (const block of blocks(text, "Process")) {
|
|
209
|
+
const inside = propertyValue(block.body, "command")
|
|
210
|
+
// `command: []` is a placeholder for a value set later: the assignment to
|
|
211
|
+
// the block's id is the command, and without one the command is computed.
|
|
212
|
+
if (inside && !/^\[\s*\]$/.test(inside.text)) {
|
|
213
|
+
rows.push(qmlRow(file, text, block, inside, block.open + 1 + inside.offset))
|
|
214
|
+
continue
|
|
215
|
+
}
|
|
216
|
+
const assigned = assignedCommand(text, block.id)
|
|
217
|
+
if (!assigned && inside) {
|
|
218
|
+
rows.push({ ...qmlRow(file, text, block, null, block.open + 1 + inside.offset), commandText: "[] (set at run time, no assignment to the id observed)" })
|
|
219
|
+
continue
|
|
220
|
+
}
|
|
221
|
+
rows.push(qmlRow(file, text, block, assigned, assigned ? assigned.offset : null))
|
|
222
|
+
}
|
|
223
|
+
rows.push(...detachedRows(file, text))
|
|
224
|
+
return rows.sort((a, b) => a.line - b.line)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** The command words of a shell segment, leading assignments and keywords dropped; null when nothing runs. */
|
|
228
|
+
function commandWords(segment) {
|
|
229
|
+
let words = shellWords(segment)
|
|
230
|
+
// A case pattern (`*)`, `[0-9]*)`) and the blanked shells of `(( ))` and
|
|
231
|
+
// `[[ ]]` run nothing; what follows a pattern on the same line does.
|
|
232
|
+
if (words.length && /\)$/.test(words[0]) && !/^\$?\(/.test(words[0])) words = words.slice(1)
|
|
233
|
+
words = words.filter((word) => !/^(?:\(\(\s*\)\)|\(\(|\)\)|\[\[|\]\])$/.test(word))
|
|
234
|
+
while (words.length && (KEYWORDS.has(words[0]) || /^[A-Za-z_]\w*\+?=/.test(words[0]))) {
|
|
235
|
+
if (words[0] === "for" || words[0] === "case" || words[0] === "select" || words[0] === "function") return null
|
|
236
|
+
words = words.slice(1)
|
|
237
|
+
}
|
|
238
|
+
if (!words.length) return null
|
|
239
|
+
if (/^[A-Za-z_]\w*\(\)$/.test(words[0])) return null
|
|
240
|
+
if (BUILTINS.has(basename(words[0])) && basename(words[0]) !== "eval") return null
|
|
241
|
+
return words
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Every command site of a shell script, per line, pipes as separate sites. */
|
|
245
|
+
export function shellProcesses(file) {
|
|
246
|
+
const rows = []
|
|
247
|
+
for (const { line, text } of shellLogicalLines(file.text)) {
|
|
248
|
+
const trimmed = blankShellExpressions(text).trim()
|
|
249
|
+
if (!trimmed || trimmed.startsWith("#")) continue
|
|
250
|
+
// A command substitution runs too: `t=$(mktemp)` is a `mktemp` site.
|
|
251
|
+
const segments = shellPieces(trimmed)
|
|
252
|
+
const lineCap = capIn(shellWords(trimmed))
|
|
253
|
+
let previous = null
|
|
254
|
+
for (const segment of segments) {
|
|
255
|
+
const argv = commandWords(segment.text)
|
|
256
|
+
if (!argv) {
|
|
257
|
+
previous = null
|
|
258
|
+
continue
|
|
259
|
+
}
|
|
260
|
+
const stripped = withoutRedirections(argv)
|
|
261
|
+
// A line that is only a redirection (`exec > log`, `> file`) runs nothing.
|
|
262
|
+
if (!stripped.length) {
|
|
263
|
+
previous = null
|
|
264
|
+
continue
|
|
265
|
+
}
|
|
266
|
+
const deadlineMs = timeoutMs(stripped)
|
|
267
|
+
const row = {
|
|
268
|
+
file: file.path,
|
|
269
|
+
line,
|
|
270
|
+
declaredIn: "shell",
|
|
271
|
+
id: null,
|
|
272
|
+
argv: stripped,
|
|
273
|
+
argvForm: "string",
|
|
274
|
+
expressions: [],
|
|
275
|
+
commandText: null,
|
|
276
|
+
running: true,
|
|
277
|
+
detached: false,
|
|
278
|
+
deadline: stripped.some((word) => basename(word) === "timeout") ? { observed: true, via: "timeout-argv", ms: deadlineMs } : { observed: false, via: null, ms: null },
|
|
279
|
+
output: { collector: "none", capObserved: lineCap.observed, via: lineCap.via },
|
|
280
|
+
shellWrapper: isShellWrapper(stripped),
|
|
281
|
+
pipedFrom: segment.operator === "|" || segment.operator === "|&" ? previous : null,
|
|
282
|
+
}
|
|
283
|
+
rows.push(row)
|
|
284
|
+
previous = { line, argv0: stripped[0] }
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return rows
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* @param {{ path: string, kind: string, text: string }} file
|
|
292
|
+
* @returns {Array} process rows, in file order
|
|
293
|
+
*/
|
|
294
|
+
export function extractProcesses(file) {
|
|
295
|
+
if (file.kind === "qml") return qmlProcesses(file)
|
|
296
|
+
if (file.kind === "shell") return shellProcesses(file)
|
|
297
|
+
return []
|
|
298
|
+
}
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
// The report for a person, drawn only with the shared vocabulary of
|
|
2
|
+
// style.mjs: `░ info` for a fact, `▒ ?` for a fact the extraction could
|
|
3
|
+
// not read, `▓ note` for a pattern row, `▔ skip` for the baseline under
|
|
4
|
+
// --offline, and the one closing word INSPECTED. It never draws `▁ ok` or
|
|
5
|
+
// `█ FAIL`, because it has no verdict to attach them to. Every section
|
|
6
|
+
// line says what was observed, and a section with nothing in it says
|
|
7
|
+
// "observed nothing of this kind", never "clean".
|
|
8
|
+
//
|
|
9
|
+
// Two views of one document. The default is what needs attention, biggest
|
|
10
|
+
// first: one block per review class this tree shows, ordered by the
|
|
11
|
+
// class's share of review findings in the M11 sample, which is the one
|
|
12
|
+
// measured number that says how much reviewers care; under each, up to
|
|
13
|
+
// five sites with the fact at each in one line; classes under five percent
|
|
14
|
+
// counted on one line. Measured before this: a listed tree with four shell
|
|
15
|
+
// scripts printed 372 process rows of two lines each, and the two rows a
|
|
16
|
+
// reviewer would act on sat under 750 lines of argv. `--full` is the
|
|
17
|
+
// exhaustive view, every site of every kind with every qualifier; `--json`
|
|
18
|
+
// is the document itself, which carries everything either view shows.
|
|
19
|
+
|
|
20
|
+
import { colourEnabled, field, GUTTER, INSPECT_VERDICT, mark, outputColumns, styler, verdict, wrap } from "../marketplace/style.mjs"
|
|
21
|
+
import { withHomeAbbreviated } from "../marketplace/paths.mjs"
|
|
22
|
+
import { PATTERNS, SIZE } from "./patterns.mjs"
|
|
23
|
+
import { toolOf } from "./processes.mjs"
|
|
24
|
+
|
|
25
|
+
const NOTHING = "observed nothing of this kind"
|
|
26
|
+
|
|
27
|
+
const plural = (count, word, words = `${word}s`) => `${count} ${count === 1 ? word : words}`
|
|
28
|
+
const site = (row) => `${row.file}:${row.line}`
|
|
29
|
+
|
|
30
|
+
/** A row: a mark, the site in bold, then the text, wrapped under the gutter. */
|
|
31
|
+
function row(state, head, text, c, extra = []) {
|
|
32
|
+
const lines = wrap(text || "(nothing)", { indent: GUTTER, first: GUTTER + head.length + 2 }, c)
|
|
33
|
+
const out = [`${mark(state, c)}${c("name", head)} ${lines[0].trimStart()}`, ...lines.slice(1)]
|
|
34
|
+
for (const line of extra) out.push(...wrap(line, { indent: GUTTER }, c))
|
|
35
|
+
return out
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function processRow(process, c) {
|
|
39
|
+
if (process.argvForm === "computed") {
|
|
40
|
+
return row("unknown", site(process), `command: ${process.commandText}`, c, ["argv not resolvable statically"])
|
|
41
|
+
}
|
|
42
|
+
const notes = []
|
|
43
|
+
notes.push(`argv ${process.argvForm}${process.declaredIn === "shell" ? " (shell line)" : ""}${process.detached ? ", detached" : ""}`)
|
|
44
|
+
if (process.expressions.length) notes.push(`${plural(process.expressions.length, "element")} computed (${process.expressions.map((entry) => entry.text).join(", ")})`)
|
|
45
|
+
if (process.shellWrapper) notes.push("shell wrapper, the script inside is not followed")
|
|
46
|
+
if (process.pipedFrom) notes.push(`piped from ${process.pipedFrom.argv0} on line ${process.pipedFrom.line}`)
|
|
47
|
+
if (process.declaredIn === "qml" && !process.detached) {
|
|
48
|
+
const deadline = process.deadline
|
|
49
|
+
notes.push(deadline.observed
|
|
50
|
+
? `deadline observed (${deadline.via}${deadline.ms !== null ? ` ${deadline.ms} ms` : ""})`
|
|
51
|
+
: "no deadline observed")
|
|
52
|
+
const output = process.output
|
|
53
|
+
if (output.collector === "none") notes.push("no collector")
|
|
54
|
+
else notes.push(`output through ${output.collector}, ${output.capObserved ? `cap observed (${output.via})` : "no cap observed"}`)
|
|
55
|
+
} else if (process.deadline.observed) notes.push(`deadline observed (${process.deadline.via}${process.deadline.ms !== null ? ` ${process.deadline.ms} ms` : ""})`)
|
|
56
|
+
return row("info", site(process), argvText(process), c, [notes.join("; ")])
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The argv as a list: literals quoted, a computed element as the source text it was read from. */
|
|
60
|
+
function argvText(process) {
|
|
61
|
+
const computed = new Set(process.expressions.map((entry) => entry.index))
|
|
62
|
+
return `[${process.argv.map((word, index) => (computed.has(index) ? word : JSON.stringify(word))).join(", ")}]`
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function hostRow(host, c) {
|
|
66
|
+
const via = host.tool ? `via ${host.tool}` : "no tool observed on the line"
|
|
67
|
+
const notes = [
|
|
68
|
+
host.timeout.observed ? `timeout observed (${host.timeout.via})` : "no timeout observed",
|
|
69
|
+
host.sizeCap.observed ? `size cap observed (${host.sizeCap.via})` : "no size cap observed",
|
|
70
|
+
]
|
|
71
|
+
if (host.tool === "curl") {
|
|
72
|
+
notes.push(host.flags.includes("-q") ? "-q observed" : "-q not observed")
|
|
73
|
+
notes.push(host.flags.includes("-L") || host.flags.includes("--location") ? "-L observed" : "-L not observed")
|
|
74
|
+
} else if (host.flags.length) notes.push(`flags ${host.flags.join(" ")}`)
|
|
75
|
+
if (host.privateAddress) notes.push("private or loopback address")
|
|
76
|
+
const lines = wrap(`${host.scheme} ${site(host)} ${via}`, { indent: GUTTER, first: GUTTER + host.host.length + 2 }, c)
|
|
77
|
+
return [`${mark("info", c)}${c("name", host.host)} ${lines[0].trimStart()}`, ...lines.slice(1), ...wrap(notes.join("; "), { indent: GUTTER }, c)]
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function writeRow(write, c) {
|
|
81
|
+
const what = write.via === "FileView" ? `FileView path: ${write.path}` : `${write.via} ${write.path}`
|
|
82
|
+
const where = write.controlledDirectory === "observed"
|
|
83
|
+
? `observed (${write.controlledBy})`
|
|
84
|
+
: write.controlledDirectory === "not-observed"
|
|
85
|
+
? `not observed${write.temp ? ` (${write.canonicalPath.split("/").slice(0, 2).join("/")} is shared)` : ""}`
|
|
86
|
+
: "unknown (path not readable as a literal prefix)"
|
|
87
|
+
const notes = [`under a directory the plugin controls: ${where}`]
|
|
88
|
+
if (write.mode) notes.push(`mode ${write.mode}`)
|
|
89
|
+
return row("info", site(write), what, c, [notes.join("; ")])
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function timerRow(timer, c) {
|
|
93
|
+
if (timer.intervalMs === null) {
|
|
94
|
+
return row("unknown", site(timer), `interval: ${timer.intervalText ?? "not declared"}`, c, ["interval not resolvable statically"])
|
|
95
|
+
}
|
|
96
|
+
const parts = [`interval ${timer.intervalMs} ms`]
|
|
97
|
+
parts.push(timer.repeat === true ? "repeat" : timer.repeat === false ? "single shot" : "repeat bound to an expression")
|
|
98
|
+
if (timer.running === true) parts.push("running")
|
|
99
|
+
else if (timer.running === null) parts.push("running bound to an expression")
|
|
100
|
+
if (timer.triggeredOnStart === true) parts.push("triggeredOnStart")
|
|
101
|
+
if (timer.startedBy) parts.push(`started by ${timer.startedBy}`)
|
|
102
|
+
else if (timer.running === false) parts.push("no start observed")
|
|
103
|
+
return row("info", site(timer), parts.join(", "), c)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function baselineLines(section, c) {
|
|
107
|
+
const out = []
|
|
108
|
+
if (section.skipped) {
|
|
109
|
+
out.push(...field("capabilities", "marketplace baseline not run", c))
|
|
110
|
+
out.push(`${mark("skipped", c)}${wrap(`skipped (${section.reason})`, { indent: GUTTER }, c)[0].trimStart()}`)
|
|
111
|
+
return out
|
|
112
|
+
}
|
|
113
|
+
out.push(...field("capabilities", `marketplace baseline at pin ${section.pin.commit.slice(0, 8)}, ${section.transport === "local-git" ? "local transport" : `transport ${section.transport}`}`, c))
|
|
114
|
+
if (!section.invoked) {
|
|
115
|
+
out.push(`${mark("unknown", c)}${wrap(`not run: ${section.skipReason}`, { indent: GUTTER }, c)[0].trimStart()}`)
|
|
116
|
+
return out
|
|
117
|
+
}
|
|
118
|
+
const official = section.official
|
|
119
|
+
if (official?.error) {
|
|
120
|
+
out.push(...row("unknown", official.error.code, official.error.message, c))
|
|
121
|
+
return out
|
|
122
|
+
}
|
|
123
|
+
const capabilities = official.capabilities || []
|
|
124
|
+
const findings = official.findings || []
|
|
125
|
+
const evidence = (entries) => entries.flatMap((entry) => entry.evidence || [])
|
|
126
|
+
const files = (entries) => [...new Set(evidence(entries).map((entry) => entry.path))]
|
|
127
|
+
if (capabilities.length) {
|
|
128
|
+
const sites = evidence(capabilities)
|
|
129
|
+
out.push(...wrap(`observed: ${capabilities.map((entry) => entry.id).join(", ")} (${plural(sites.length, "evidence site")}, ${files(capabilities).join(", ")})`, { indent: GUTTER, first: 0 }, c)
|
|
130
|
+
.map((line, index) => (index === 0 ? `${mark("info", c)}${line}` : line)))
|
|
131
|
+
} else {
|
|
132
|
+
out.push(`${mark("info", c)}observed: no capability recorded`)
|
|
133
|
+
}
|
|
134
|
+
for (const finding of findings) {
|
|
135
|
+
const sites = (finding.evidence || []).map((entry) => `${entry.path}:${entry.line}`)
|
|
136
|
+
out.push(...wrap(`observed: finding ${finding.ruleId} (${sites.join(", ")})`, { indent: GUTTER, first: 0 }, c)
|
|
137
|
+
.map((line, index) => (index === 0 ? `${mark("info", c)}${line}` : line)))
|
|
138
|
+
}
|
|
139
|
+
out.push(...wrap(`official result: ${official.outcome} (verbatim in --json under marketplaceBaseline)`, { indent: GUTTER }, c))
|
|
140
|
+
return out
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function patternLines(document, c) {
|
|
144
|
+
if (!PATTERNS.length) return []
|
|
145
|
+
const out = []
|
|
146
|
+
const width = Math.max(...PATTERNS.map((pattern) => pattern.label.length)) + 1
|
|
147
|
+
out.push(...field("patterns", document.patterns.length
|
|
148
|
+
? `of what the marketplace's human review raised, in a ${PATTERNS[0].sample} (${PATTERNS[0].measurement})`
|
|
149
|
+
: `none of the ${PATTERNS.length} classes the marketplace's human review raised, in a ${PATTERNS[0].sample} (${PATTERNS[0].measurement}), shows its precondition here`, c))
|
|
150
|
+
for (const entry of document.patterns) {
|
|
151
|
+
const pattern = PATTERNS.find((candidate) => candidate.id === entry.id)
|
|
152
|
+
const label = pattern.label.padEnd(width)
|
|
153
|
+
const lines = wrap(entry.observation, { indent: GUTTER, first: GUTTER + label.length + 1 }, c)
|
|
154
|
+
out.push(`${mark("advisory", c)}${c("name", label)} ${lines[0].trimStart()}`, ...lines.slice(1))
|
|
155
|
+
out.push(...wrap(`about ${Math.round(entry.share * 100)} of every 100 review findings in the sample (${entry.measurement})`, { indent: GUTTER }, c))
|
|
156
|
+
}
|
|
157
|
+
out.push("")
|
|
158
|
+
const lookedFor = document.lookedFor.map((id) => PATTERNS.find((pattern) => pattern.id === id)?.notObserved || id)
|
|
159
|
+
out.push(...field("not observed", lookedFor.length ? lookedFor.join(", ") : "every pattern's precondition was observed", c))
|
|
160
|
+
return out
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The default view: what needs attention, biggest first. One block per
|
|
165
|
+
* review class this tree shows, ordered by the class's share of review
|
|
166
|
+
* findings in the M11 sample, which is the one measured number that says
|
|
167
|
+
* how much reviewers care; under each, the sites that show it, up to
|
|
168
|
+
* SHOWN_SITES, with the fact at each site in one line. Classes under
|
|
169
|
+
* MIN_SHARE are counted on one line and not listed. Nothing else is
|
|
170
|
+
* printed: the counts of what was observed go in the closing line, and
|
|
171
|
+
* `--full` has every site of every kind.
|
|
172
|
+
*/
|
|
173
|
+
const MIN_SHARE = 0.05
|
|
174
|
+
const SHOWN_SITES = 5
|
|
175
|
+
|
|
176
|
+
/** A shell word for the eye: quoted only when it holds a space or a quote, an expression element as its source text. */
|
|
177
|
+
function commandLine(process) {
|
|
178
|
+
const computed = new Set(process.expressions.map((entry) => entry.index))
|
|
179
|
+
return process.argv.map((word, index) => (computed.has(index) || !/[\s"'`]/.test(word) ? word : JSON.stringify(word))).join(" ")
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The fact at a site, in one line: the write there for the file class, the
|
|
184
|
+
* host there for the egress class, otherwise the command, then the host,
|
|
185
|
+
* then the write, whichever the site has.
|
|
186
|
+
*/
|
|
187
|
+
function factAt(document, at, patternId) {
|
|
188
|
+
const same = (row) => `${row.file}:${row.line}` === at
|
|
189
|
+
const process = () => {
|
|
190
|
+
const row = document.observed.processes.find(same)
|
|
191
|
+
return row ? (row.argvForm === "computed" ? `command: ${row.commandText}` : commandLine(row) || "[] (an empty argv)") : ""
|
|
192
|
+
}
|
|
193
|
+
const host = () => {
|
|
194
|
+
const row = document.observed.hosts.find(same)
|
|
195
|
+
return row ? `${row.scheme}://${row.host}${row.tool ? ` via ${row.tool}` : ""}` : ""
|
|
196
|
+
}
|
|
197
|
+
const write = () => {
|
|
198
|
+
const row = document.observed.writes.find(same)
|
|
199
|
+
return row ? `${row.via} ${row.canonicalPath ?? row.path}` : ""
|
|
200
|
+
}
|
|
201
|
+
const order = patternId === "file-and-state-boundary" ? [write, process, host] : patternId === "network-egress" ? [host, process, write] : [process, host, write]
|
|
202
|
+
for (const fact of order) {
|
|
203
|
+
const text = fact()
|
|
204
|
+
if (text) return text
|
|
205
|
+
}
|
|
206
|
+
return ""
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Text cut to a width with three dots, for a command a person only needs to recognise; --full has it whole. */
|
|
210
|
+
function cut(text, width) {
|
|
211
|
+
return text.length <= width ? text : `${text.slice(0, Math.max(0, width - 3))}...`
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function baselineText(section) {
|
|
215
|
+
if (section.skipped) return `skipped (${section.reason})`
|
|
216
|
+
if (!section.invoked) return `not run: ${section.skipReason}`
|
|
217
|
+
const official = section.official
|
|
218
|
+
if (official?.error) return `the official code refused the snapshot: ${official.error.code}`
|
|
219
|
+
const names = [...(official.capabilities || []).map((entry) => entry.id), ...(official.findings || []).map((entry) => entry.ruleId)]
|
|
220
|
+
return `${official.outcome} at pin ${section.pin.commit.slice(0, 8)}${names.length ? `: ${names.join(", ")}` : ""}`
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* @param {object} document the document of docs/INSPECT.md
|
|
225
|
+
* @param {{ colour?: boolean, full?: boolean }} [options] `full` is the
|
|
226
|
+
* exhaustive view behind `--full`; the default is what needs attention,
|
|
227
|
+
* biggest first.
|
|
228
|
+
* @returns {string}
|
|
229
|
+
*/
|
|
230
|
+
export function renderInspect(document, { colour = colourEnabled(), full = false } = {}) {
|
|
231
|
+
if (full) return renderFull(document, { colour })
|
|
232
|
+
const c = styler(colour)
|
|
233
|
+
const out = []
|
|
234
|
+
const counts = document.counts
|
|
235
|
+
out.push(...field("subject", `${withHomeAbbreviated(document.subject.dir)} at ${document.subject.commit ? document.subject.commit.slice(0, 8) : "no commit"}`, c))
|
|
236
|
+
out.push(...field("baseline", baselineText(document.marketplaceBaseline), c))
|
|
237
|
+
const score = document.size.score
|
|
238
|
+
out.push(...field("size score", score === null
|
|
239
|
+
? `none: no function to rank`
|
|
240
|
+
: `${score.toFixed(2)} of 10; 10 minus the mean rank of its ${plural(counts.functions, "function")} among ${SIZE.functions} in ${document.size.sample.trees} listed trees (${SIZE.measurement}), so a tree of median functions scores 5.00`, c))
|
|
241
|
+
out.push("")
|
|
242
|
+
|
|
243
|
+
const ranked = [...document.patterns].sort((a, b) => b.share - a.share)
|
|
244
|
+
const shown = ranked.filter((entry) => entry.share >= MIN_SHARE)
|
|
245
|
+
const below = ranked.filter((entry) => entry.share < MIN_SHARE)
|
|
246
|
+
const width = outputColumns()
|
|
247
|
+
const over = document.size.over
|
|
248
|
+
if (!ranked.length && !over.length) {
|
|
249
|
+
out.push(...field("attention", `nothing: no function over the size of ${SIZE.sample} (${SIZE.measurement}), and none of the ${PATTERNS.length} classes reviewers raise shows in this tree (${PATTERNS[0]?.measurement || "M11"})`, c))
|
|
250
|
+
} else {
|
|
251
|
+
out.push(...field("attention", `${over.length ? `long functions first, by length, then ` : ""}${plural(shown.length, "class", "classes")} reviewers raise, biggest first by share of review findings (${PATTERNS[0].measurement}); up to ${SHOWN_SITES} sites each`, c))
|
|
252
|
+
}
|
|
253
|
+
// Long functions first: what the person asked about, so the order is a
|
|
254
|
+
// preference and the heading says whose thresholds it uses.
|
|
255
|
+
if (over.length) {
|
|
256
|
+
out.push("")
|
|
257
|
+
const thresholds = `${SIZE.lines} lines, ${SIZE.branches} branches or nesting ${SIZE.depth}`
|
|
258
|
+
const heading = wrap(`${plural(over.length, "function")} over what 90 of 100 functions in ${SIZE.sample} stay under: ${thresholds} (${SIZE.measurement}); rank is the share of listed functions smaller than it`, { indent: GUTTER, first: GUTTER + "long functions".length + 2 }, c)
|
|
259
|
+
out.push(`${mark("advisory", c)}${c("name", "long functions")} ${heading[0].trimStart()}`, ...heading.slice(1))
|
|
260
|
+
const top = over.slice(0, SHOWN_SITES)
|
|
261
|
+
const siteWidth = Math.max(...top.map((entry) => `${entry.file}:${entry.line}`.length))
|
|
262
|
+
const nameWidth = Math.max(...top.map((entry) => entry.name.length))
|
|
263
|
+
for (const entry of top) {
|
|
264
|
+
const at = `${entry.file}:${entry.line}`
|
|
265
|
+
out.push(`${" ".repeat(GUTTER)}${c("name", at.padEnd(siteWidth))} ${entry.name.padEnd(nameWidth)} ${entry.lines} lines, ${entry.branches} branches, nesting ${entry.depth}, ${c("label", `rank ${Math.round(entry.percentile)}`)}`)
|
|
266
|
+
}
|
|
267
|
+
if (over.length > SHOWN_SITES) out.push(...wrap(`and ${over.length - SHOWN_SITES} more (--full)`, { indent: GUTTER }).map((line) => c("label", line)))
|
|
268
|
+
}
|
|
269
|
+
for (const entry of shown) {
|
|
270
|
+
const pattern = PATTERNS.find((candidate) => candidate.id === entry.id)
|
|
271
|
+
out.push("")
|
|
272
|
+
const share = `${Math.round(entry.share * 100)} of 100 findings`
|
|
273
|
+
const heading = wrap(entry.summary, { indent: GUTTER, first: GUTTER + pattern.label.length + 2 + share.length + 2 }, c)
|
|
274
|
+
out.push(`${mark("advisory", c)}${c("name", pattern.label)} ${c("label", share)} ${heading[0].trimStart()}`, ...heading.slice(1))
|
|
275
|
+
// A site cited twice by one class (curl from PATH and curl without -q) is one line.
|
|
276
|
+
const sites = [...new Set(entry.sites.map((site_) => `${site_.file}:${site_.line}`))]
|
|
277
|
+
const siteWidth = Math.max(...sites.slice(0, SHOWN_SITES).map((at) => at.length))
|
|
278
|
+
for (const at of sites.slice(0, SHOWN_SITES)) {
|
|
279
|
+
const fact = factAt(document, at, entry.id)
|
|
280
|
+
const room = width - GUTTER - siteWidth - 2
|
|
281
|
+
out.push(`${" ".repeat(GUTTER)}${c("name", at.padEnd(siteWidth))}${fact ? ` ${cut(fact, room)}` : ""}`)
|
|
282
|
+
}
|
|
283
|
+
if (sites.length > SHOWN_SITES) out.push(...wrap(`and ${sites.length - SHOWN_SITES} more (--full)`, { indent: GUTTER }).map((line) => c("label", line)))
|
|
284
|
+
}
|
|
285
|
+
if (below.length) {
|
|
286
|
+
out.push("")
|
|
287
|
+
out.push(...field(`under ${Math.round(MIN_SHARE * 100)}%`, below.map((entry) => `${PATTERNS.find((candidate) => candidate.id === entry.id)?.label || entry.id} (${plural(entry.observedCount, "site")})`).join(", "), c))
|
|
288
|
+
}
|
|
289
|
+
out.push("")
|
|
290
|
+
const processes = `${plural(counts.processes.total, "process", "processes")}${counts.processes.total ? ` (${split(counts.processes)})` : ""}`
|
|
291
|
+
out.push(...verdict("info", INSPECT_VERDICT, `${processes}, ${plural(counts.hosts, "host")}, ${plural(counts.writes, "write")}, ${plural(counts.timers, "timer")} observed; static; --full for every site, --json for the document`, c))
|
|
292
|
+
return out.join("\n")
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** The exhaustive view: every site, every qualifier, the whole not observed and not visible lists. */
|
|
296
|
+
function renderFull(document, { colour }) {
|
|
297
|
+
const c = styler(colour)
|
|
298
|
+
const out = []
|
|
299
|
+
const read = document.subject.filesRead
|
|
300
|
+
const kinds = Object.entries(read).filter(([, count]) => count > 0).map(([kind, count]) => `${count} ${kind}`)
|
|
301
|
+
const total = Object.values(read).reduce((sum, count) => sum + count, 0)
|
|
302
|
+
out.push(...field("subject", `${withHomeAbbreviated(document.subject.dir)} at ${document.subject.commit ? document.subject.commit.slice(0, 8) : "no commit"}, ${plural(total, "file")} read${kinds.length ? ` (${kinds.join(", ")})` : ""}`, c))
|
|
303
|
+
out.push(...field("method", document.method, c))
|
|
304
|
+
out.push("")
|
|
305
|
+
|
|
306
|
+
const sections = [
|
|
307
|
+
["processes", document.observed.processes, processRow],
|
|
308
|
+
["hosts", document.observed.hosts, hostRow],
|
|
309
|
+
["writes", document.observed.writes, writeRow],
|
|
310
|
+
["timers", document.observed.timers, timerRow],
|
|
311
|
+
]
|
|
312
|
+
for (const [name, rows, render] of sections) {
|
|
313
|
+
out.push(...field(name, rows.length ? `observed ${rows.length}${name === "processes" ? `, ${split(document.counts.processes)}` : ""}` : NOTHING, c))
|
|
314
|
+
for (const entry of rows) out.push(...render(entry, c))
|
|
315
|
+
out.push("")
|
|
316
|
+
}
|
|
317
|
+
const functions = document.observed.functions
|
|
318
|
+
const over = document.size.over
|
|
319
|
+
out.push(...field("functions", functions.length
|
|
320
|
+
? `observed ${functions.length}, ${over.length} over what 90 of 100 functions in ${SIZE.sample} stay under (${SIZE.lines} lines, ${SIZE.branches} branches or nesting ${SIZE.depth}; ${SIZE.measurement}), longest first`
|
|
321
|
+
: NOTHING, c))
|
|
322
|
+
for (const entry of over) out.push(...row("info", `${entry.file}:${entry.line}`, `${entry.name}, ${plural(entry.lines, "line")}, ${plural(entry.branches, "branch", "branches")}, nesting ${entry.depth}, over ${entry.percentile} of 100 listed`, c))
|
|
323
|
+
if (functions.length) out.push(...wrap(`size score ${document.size.score.toFixed(2)} of 10: 10 minus the mean rank of the ${functions.length} among ${SIZE.functions} listed functions (${SIZE.measurement})`, { indent: GUTTER }, c))
|
|
324
|
+
out.push("")
|
|
325
|
+
out.push(...baselineLines(document.marketplaceBaseline, c))
|
|
326
|
+
out.push("")
|
|
327
|
+
const patterns = patternLines(document, c)
|
|
328
|
+
if (patterns.length) out.push(...patterns)
|
|
329
|
+
out.push(...field("not visible", document.notVisible.join(", "), c))
|
|
330
|
+
out.push("")
|
|
331
|
+
const counts = document.counts
|
|
332
|
+
const processes = `${plural(counts.processes.total, "process", "processes")}${counts.processes.total ? ` (${split(counts.processes)})` : ""}`
|
|
333
|
+
out.push(...verdict("info", INSPECT_VERDICT, `${processes}, ${plural(counts.hosts, "host")}, ${plural(counts.writes, "write")}, ${plural(counts.timers, "timer")}; static, see docs/INSPECT.md`, c))
|
|
334
|
+
return out.join("\n")
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** The process split in words: how many are QML Process sites and how many are shell lines. */
|
|
338
|
+
function split({ qml, shell }) {
|
|
339
|
+
if (!shell) return qml === 1 ? "in qml" : "all in qml"
|
|
340
|
+
if (!qml) return shell === 1 ? "a shell line" : "all shell lines"
|
|
341
|
+
return `${qml} in qml, ${plural(shell, "shell line")}`
|
|
342
|
+
}
|