omakit 0.4.2 → 0.5.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.
@@ -0,0 +1,355 @@
1
+ // The review patterns of docs/INSPECT.md as data: for each class the
2
+ // marketplace's human review raised in the M11 sample, a precondition over
3
+ // the observed facts, the measurement it cites and the share it prints. A
4
+ // pattern row prints only where the precondition is met, in two lines: the
5
+ // observation with its sites, then "about N of every 100 review findings in
6
+ // the sample (M11)". Never "missing", "should" or "fix": the row names the
7
+ // observation and the frequency and stops. A pattern without a number does
8
+ // not ship; tests/unit/submit.test.mjs holds every entry here to a
9
+ // measurement that names a section of docs/MEASUREMENTS.md and a numeric
10
+ // share, the way it holds every submit check to a why.
11
+ //
12
+ // The shares are the maintainer's review findings by class in a fixed
13
+ // 30-issue sample, one reader's classification, dated 2026-09-12
14
+ // (docs/evidence/inspect/2026-09-12-review-classes.json). They say how
15
+ // often reviewers raised a class, never how likely this plugin is to be
16
+ // blocked, and nothing here turns a share into a verdict.
17
+ //
18
+ // Nothing here restates a marketplace rule (AGENTS.md, rule 2). The
19
+ // `supply-chain` pattern detects nothing itself: it cites the findings the
20
+ // pinned baseline recorded and does not list as selectively blocking, which
21
+ // at the current pin are its three unpinned-remote-source rules, read
22
+ // through verify and the pinned policy rather than named here.
23
+
24
+ import { basename, blankComments, blocks, lineOf, propertyValue } from "./text.mjs"
25
+ import { toolOf } from "./processes.mjs"
26
+
27
+ const SAMPLE = "30-issue sample"
28
+ const site = (row) => ({ file: row.file, line: row.line })
29
+ const sites = (rows) => rows.map((row) => `${row.file}:${row.line}`).join(", ")
30
+ const plural = (count, word, words = `${word}s`) => `${count} ${count === 1 ? word : words}`
31
+
32
+ /** A short flag, alone or inside a cluster: `-q`, `-fsq`; or its long form. */
33
+ function hasFlag(flags, letter, long = null) {
34
+ return flags.some((flag) => flag === `-${letter}` || (long && (flag === long || flag.startsWith(`${long}=`))) || (/^-[A-Za-z]+$/.test(flag) && flag.includes(letter)))
35
+ }
36
+
37
+ /** The tool a process resolves through PATH: its first non-wrapper word when that word has no slash. */
38
+ function pathResolved(process) {
39
+ if (!Array.isArray(process.argv) || !process.argv.length) return null
40
+ const { tool } = toolOf(process.argv)
41
+ if (!tool || tool.includes("/")) return null
42
+ return tool
43
+ }
44
+
45
+ const SECRET = /authorization|bearer|token=|api[_-]?key|password|secret=/i
46
+ const PRIVILEGED = new Set(["sudo", "pkexec", "doas", "docker"])
47
+
48
+ /**
49
+ * `Text` blocks whose `text:` binds to a collector's `.text` and whose
50
+ * `textFormat` is not `Text.PlainText`, over the QML files.
51
+ */
52
+ function richTextSinks(files) {
53
+ const found = []
54
+ for (const file of files.filter((entry) => entry.kind === "qml")) {
55
+ const text = blankComments(file.text)
56
+ const collectors = [...blocks(text, "StdioCollector"), ...blocks(text, "SplitParser")].map((block) => block.id).filter(Boolean)
57
+ if (!collectors.length) continue
58
+ for (const block of [...blocks(text, "Text"), ...blocks(text, "Label"), ...blocks(text, "TextEdit")]) {
59
+ const bound = propertyValue(block.body, "text")
60
+ if (!bound || !collectors.some((id) => new RegExp(`(?<![\\w.])${id}\\.(?:text|data)\\b`).test(bound.text))) continue
61
+ const format = propertyValue(block.body, "textFormat")?.text
62
+ if (format === "Text.PlainText") continue
63
+ found.push({ file: file.path, line: lineOf(text, block.open + 1 + bound.offset) })
64
+ }
65
+ }
66
+ return found
67
+ }
68
+
69
+ /** `console.log(...)` and `console.warn(...)` lines whose argument text is secret-shaped, over QML and JavaScript. */
70
+ function secretLogs(files) {
71
+ const found = []
72
+ for (const file of files.filter((entry) => entry.kind === "qml" || entry.kind === "js")) {
73
+ const text = blankComments(file.text)
74
+ for (const match of text.matchAll(/console\.(?:log|warn|error|info|debug)\s*\(([^\n]*)/g)) {
75
+ if (SECRET.test(match[1])) found.push({ file: file.path, line: lineOf(text, match.index) })
76
+ }
77
+ }
78
+ return found
79
+ }
80
+
81
+ /**
82
+ * The size thresholds: a function is listed as long when it is longer, more
83
+ * branched or deeper than 90 of 100 functions in the 50 listed trees of
84
+ * docs/evidence/inspect/2026-09-16-function-lengths.json (M12). The numbers
85
+ * are that record's p90 quantiles and tests/unit/submit.test.mjs holds them
86
+ * to it. Length is what the person asked for first, so the default view
87
+ * lists long functions before the review classes and says that this order
88
+ * is a preference and not a share.
89
+ */
90
+ export const SIZE = Object.freeze({
91
+ measurement: "M12",
92
+ sample: "50 listed trees, 6041 functions",
93
+ trees: 50,
94
+ functions: 6041,
95
+ lines: 22,
96
+ branches: 6,
97
+ depth: 2,
98
+ // The histogram of each measure over the 6041 functions, value to count,
99
+ // from which a function's percentile rank among listed functions is read;
100
+ // and each listed tree's heavyShare, the share of its function lines in
101
+ // functions over the thresholds, in the record's row order, from which
102
+ // a tree's position among listed trees is read.
103
+ distribution: Object.freeze({
104
+ lines: Object.freeze({ 1: 330, 2: 185, 3: 682, 4: 659, 5: 629, 6: 515, 7: 420, 8: 339, 9: 301, 10: 235, 11: 192, 12: 138, 13: 150, 14: 129, 15: 104, 16: 75, 17: 84, 18: 64, 19: 70, 20: 48, 21: 51, 22: 46, 23: 45, 24: 42, 25: 27, 26: 25, 27: 16, 28: 24, 29: 24, 30: 17, 31: 18, 32: 23, 33: 18, 34: 19, 35: 10, 36: 5, 37: 11, 38: 12, 39: 12, 40: 9, 41: 10, 42: 12, 43: 9, 44: 8, 45: 7, 46: 14, 47: 8, 48: 11, 49: 8, 50: 6, 51: 2, 52: 2, 53: 6, 54: 2, 55: 7, 56: 4, 57: 3, 58: 5, 60: 6, 61: 2, 62: 3, 63: 6, 64: 1, 65: 3, 66: 1, 67: 3, 68: 4, 69: 8, 70: 5, 71: 6, 72: 2, 73: 2, 74: 1, 75: 1, 76: 1, 77: 1, 78: 1, 79: 3, 80: 1, 81: 3, 82: 1, 83: 1, 85: 1, 87: 1, 89: 1, 90: 2, 91: 2, 92: 1, 94: 1, 96: 2, 97: 2, 98: 1, 99: 1, 100: 1, 101: 1, 102: 2, 104: 1, 108: 1, 110: 2, 111: 1, 112: 1, 113: 1, 115: 2, 116: 1, 117: 1, 119: 1, 120: 1, 121: 1, 128: 2, 129: 1, 133: 1, 134: 1, 144: 1, 145: 1, 154: 1, 161: 1, 173: 1, 253: 1, 277: 1, 292: 1, 365: 1, 495: 1 }),
105
+ branches: Object.freeze({ 0: 2336, 1: 1096, 2: 807, 3: 455, 4: 339, 5: 233, 6: 171, 7: 125, 8: 90, 9: 68, 10: 47, 11: 53, 12: 24, 13: 26, 14: 18, 15: 11, 16: 17, 17: 18, 18: 12, 19: 12, 20: 11, 21: 3, 22: 5, 23: 7, 24: 8, 25: 4, 26: 1, 27: 6, 28: 4, 29: 5, 31: 1, 32: 3, 33: 3, 34: 1, 35: 1, 36: 4, 37: 1, 38: 1, 40: 1, 41: 1, 43: 2, 44: 1, 45: 2, 46: 1, 64: 1, 75: 1, 78: 1, 85: 1, 87: 2 }),
106
+ depth: Object.freeze({ 0: 3794, 1: 1421, 2: 481, 3: 155, 4: 80, 5: 62, 6: 27, 7: 10, 8: 5, 9: 2, 12: 1, 13: 1, 15: 1, 18: 1 }),
107
+ heavyShare: Object.freeze([0.421, 0.3361, 0.4049, 0, 0.3687, 0.4469, 0.4107, 0.6167, 0.2635, 0.8434, 0, 0, 0.193, 0.7036, 0.4506, 0.4846, 0.5047, 0.5337, 0.3628, 0.682, 0.0554, 0.7775, 0.3548, 0.3552, 0, 0.7172, 0.2542, 0.3747, 0.4275, 0.6607, 0.4322, 0.6941, 0.3247, 0.5225, 0.2545, 0.1462, 0, 0.5817, 0.5891, 0.4353, 0, 0.3479, 0.6109, 0.296, 0.3882, 0.5624, 0.6783, 0.4385, 0.6078, 0.5826]),
108
+ }),
109
+ })
110
+
111
+
112
+ /**
113
+ * Where a value sits among the sample's: the share of listed functions
114
+ * with a smaller value, as a percentage, so the smallest listed value ranks
115
+ * 0 and a value over every listed one ranks 100. A function's rank is the
116
+ * largest of its three, since one long measure is what a reader sees.
117
+ */
118
+ export function percentile(measure, value) {
119
+ const histogram = SIZE.distribution[measure]
120
+ let below = 0
121
+ for (const [key, count] of Object.entries(histogram)) if (Number(key) < value) below += count
122
+ return Math.round((below / SIZE.functions) * 1000) / 10
123
+ }
124
+
125
+ export function rankOf(entry) {
126
+ return Math.max(percentile("lines", entry.lines), percentile("branches", entry.branches), percentile("depth", entry.depth))
127
+ }
128
+
129
+ /**
130
+ * The share of a tree's function lines that sit in functions over any of
131
+ * the M12 thresholds: lines inside `overSize` functions over lines inside
132
+ * every function, 0 with no function. Line-weighted on purpose: splitting
133
+ * one long function into short ones moves its lines out of the heavy set,
134
+ * and padding a tree with small functions barely moves the ratio.
135
+ */
136
+ export function heavyShare(functions) {
137
+ const total = functions.reduce((sum, entry) => sum + entry.lines, 0)
138
+ if (!total) return 0
139
+ const heavy = overSize(functions).reduce((sum, entry) => sum + entry.lines, 0)
140
+ return heavy / total
141
+ }
142
+
143
+ /**
144
+ * The share of listed trees whose heavyShare is strictly smaller than this
145
+ * one, as a percentage over the trees of the M12 record: 0 for a tree with
146
+ * no function over the thresholds, 100 for one heavier than every listed
147
+ * tree.
148
+ */
149
+ export function treeRank(share) {
150
+ const below = SIZE.distribution.heavyShare.filter((listed) => listed < share).length
151
+ return Math.round((below / SIZE.trees) * 1000) / 10
152
+ }
153
+
154
+ /**
155
+ * The size score of a tree: 10 minus its rank among the listed trees
156
+ * divided by 10, on 0 to 10 with two decimals. A tree with no function
157
+ * over the thresholds scores 10.00, a tree heavier than every listed tree
158
+ * 0.00. It says where the tree sits among listed plugins by how much of
159
+ * its function text is in long functions, never whether it is good, and a
160
+ * tree with no function has no score.
161
+ */
162
+ export function sizeScore(functions) {
163
+ if (!functions.length) return null
164
+ return Math.round((10 - treeRank(heavyShare(functions)) / 10) * 100) / 100
165
+ }
166
+
167
+ /** The functions over any of the thresholds, longest first, then most branched. */
168
+ export function overSize(functions) {
169
+ return functions
170
+ .filter((entry) => entry.lines > SIZE.lines || entry.branches > SIZE.branches || entry.depth > SIZE.depth)
171
+ .sort((a, b) => b.lines - a.lines || b.branches - a.branches || b.depth - a.depth || a.file.localeCompare(b.file) || a.line - b.line)
172
+ }
173
+
174
+ export const PATTERNS = Object.freeze([
175
+ {
176
+ id: "process-lifecycle",
177
+ label: "process lifecycle",
178
+ notObserved: "no process without a deadline",
179
+ measurement: "M11",
180
+ share: 0.20,
181
+ sample: SAMPLE,
182
+ precondition: ({ processes }) => {
183
+ const rows = processes.filter((row) => row.declaredIn === "qml" && !row.detached && !row.deadline.observed)
184
+ return { sites: rows.map(site), observation: `observed ${plural(rows.length, "process", "processes")} with no deadline (${sites(rows)})` }
185
+ },
186
+ },
187
+ {
188
+ id: "unbounded-buffering",
189
+ label: "unbounded buffering",
190
+ notObserved: "no collector without a cap",
191
+ measurement: "M11",
192
+ share: 0.19,
193
+ sample: SAMPLE,
194
+ precondition: ({ processes }) => {
195
+ const rows = processes.filter((row) => (row.output.collector === "StdioCollector" || row.output.collector === "SplitParser") && !row.output.capObserved)
196
+ return { sites: rows.map(site), observation: `observed ${plural(rows.length, "collector")} with no cap (${sites(rows)})` }
197
+ },
198
+ },
199
+ {
200
+ id: "file-and-state-boundary",
201
+ label: "file and state boundary",
202
+ notObserved: "no write outside a controlled directory",
203
+ measurement: "M11",
204
+ share: 0.15,
205
+ sample: SAMPLE,
206
+ precondition: ({ writes }) => {
207
+ const outside = writes.filter((row) => row.controlledDirectory === "not-observed" && row.via !== "mktemp")
208
+ const unmoded = writes.filter((row) => row.via === "mkdir" && row.mode === null)
209
+ const parts = []
210
+ if (outside.length) parts.push(`${plural(outside.length, "write")} outside a controlled directory (${sites(outside)})`)
211
+ if (unmoded.length) parts.push(`${plural(unmoded.length, "mkdir")} with no mode (${sites(unmoded)})`)
212
+ return { sites: [...outside, ...unmoded].map(site), observation: `observed ${parts.join("; ")}` }
213
+ },
214
+ },
215
+ {
216
+ id: "environment-trust",
217
+ label: "environment trust",
218
+ notObserved: "no tool resolved from PATH, no curl without -q",
219
+ measurement: "M11",
220
+ share: 0.07,
221
+ sample: SAMPLE,
222
+ precondition: ({ processes, hosts }) => {
223
+ const resolved = processes.map((row) => ({ row, tool: pathResolved(row) })).filter((entry) => entry.tool)
224
+ const curls = hosts.filter((row) => row.tool === "curl" && !hasFlag(row.flags, "q", "--disable"))
225
+ const parts = []
226
+ const names = [...new Set(resolved.map((entry) => entry.tool))]
227
+ const named = names.length > 6 ? `${names.slice(0, 6).join(", ")} and ${names.length - 6} more` : names.join(", ")
228
+ if (resolved.length) parts.push(`${plural(resolved.length, "tool")} resolved from PATH (${named}; ${sites(resolved.map((entry) => entry.row))})`)
229
+ if (curls.length) parts.push(`curl without -q (${sites(curls)})`)
230
+ return { sites: [...resolved.map((entry) => entry.row), ...curls].map(site), observation: `observed ${parts.join("; ")}` }
231
+ },
232
+ },
233
+ {
234
+ id: "secrets",
235
+ label: "secrets",
236
+ notObserved: "no secret-shaped argv or log line",
237
+ measurement: "M11",
238
+ share: 0.07,
239
+ sample: SAMPLE,
240
+ precondition: ({ processes, files }) => {
241
+ const argv = processes.filter((row) => Array.isArray(row.argv) && row.argv.some((word) => SECRET.test(word)))
242
+ const copies = processes.filter((row) => Array.isArray(row.argv) && basename(toolOf(row.argv).tool || "") === "wl-copy" && row.expressions.length > 0)
243
+ const logs = secretLogs(files)
244
+ const parts = []
245
+ if (argv.length) parts.push(`secret-shaped argv (${sites(argv)})`)
246
+ if (logs.length) parts.push(`secret-shaped log line (${sites(logs)})`)
247
+ if (copies.length) parts.push(`wl-copy with a computed value (${sites(copies)})`)
248
+ return { sites: [...argv, ...logs, ...copies].map(site), observation: `observed ${parts.join("; ")}` }
249
+ },
250
+ },
251
+ {
252
+ id: "supply-chain",
253
+ label: "supply chain",
254
+ notObserved: "no unpinned remote source in the baseline",
255
+ measurement: "M11",
256
+ also: ["M4"],
257
+ share: 0.07,
258
+ sample: SAMPLE,
259
+ precondition: ({ baseline, blockingRules }) => {
260
+ const findings = (baseline?.official?.findings || []).filter((finding) => !blockingRules.includes(finding.ruleId))
261
+ const evidence = findings.flatMap((finding) => (finding.evidence || []).map((entry) => ({ file: entry.path, line: entry.line })))
262
+ return {
263
+ sites: evidence,
264
+ observation: `observed ${plural(findings.length, "baseline finding")} the pin does not block on (${findings.map((finding) => finding.ruleId).join(", ")}; ${sites(evidence)})`,
265
+ }
266
+ },
267
+ },
268
+ {
269
+ id: "network-egress",
270
+ label: "network egress",
271
+ notObserved: "no http scheme, no -L without --proto, no private address",
272
+ measurement: "M11",
273
+ share: 0.05,
274
+ sample: SAMPLE,
275
+ precondition: ({ hosts }) => {
276
+ const plain = hosts.filter((row) => row.scheme === "http")
277
+ const redirects = hosts.filter((row) => row.tool === "curl" && hasFlag(row.flags, "L", "--location") && !row.flags.some((flag) => flag.startsWith("--proto")))
278
+ const local = hosts.filter((row) => row.privateAddress)
279
+ const parts = []
280
+ if (plain.length) parts.push(`http scheme (${sites(plain)})`)
281
+ if (redirects.length) parts.push(`curl -L without --proto (${sites(redirects)})`)
282
+ if (local.length) parts.push(`private or loopback address (${sites(local)})`)
283
+ return { sites: [...plain, ...redirects, ...local].map(site), observation: `observed ${parts.join("; ")}` }
284
+ },
285
+ },
286
+ {
287
+ id: "untrusted-text-to-display",
288
+ label: "untrusted text to display",
289
+ notObserved: "no Text bound to output without Text.PlainText",
290
+ measurement: "M11",
291
+ share: 0.05,
292
+ sample: SAMPLE,
293
+ precondition: ({ files }) => {
294
+ const found = richTextSinks(files)
295
+ return { sites: found, observation: `observed ${plural(found.length, "Text")} bound to a collector's output without Text.PlainText (${sites(found)})` }
296
+ },
297
+ },
298
+ {
299
+ id: "argument-grammar",
300
+ label: "argument grammar",
301
+ notObserved: "no expression inside an argv element",
302
+ measurement: "M11",
303
+ share: 0.04,
304
+ sample: SAMPLE,
305
+ precondition: ({ processes }) => {
306
+ const rows = processes.filter((row) => row.expressions.length > 0)
307
+ return { sites: rows.map(site), observation: `observed ${plural(rows.length, "argv")} with a computed element (${sites(rows)})` }
308
+ },
309
+ },
310
+ {
311
+ id: "privilege-disclosure",
312
+ label: "privilege disclosure",
313
+ notObserved: "no sudo, pkexec, docker or /dev/input in argv",
314
+ measurement: "M11",
315
+ also: ["M4"],
316
+ share: 0.03,
317
+ sample: SAMPLE,
318
+ precondition: ({ processes, readme }) => {
319
+ const found = []
320
+ for (const row of processes) {
321
+ if (!Array.isArray(row.argv)) continue
322
+ const names = [...new Set(row.argv.flatMap((word) => (PRIVILEGED.has(basename(word)) ? [basename(word)] : word.includes("/dev/input") ? ["/dev/input"] : [])))]
323
+ if (names.length) found.push({ row, names })
324
+ }
325
+ const names = [...new Set(found.flatMap((entry) => entry.names))]
326
+ const named = names.filter((name) => readme && new RegExp(name.replace(/[/]/g, "\\/"), "i").test(readme))
327
+ const readmeNote = readme === null ? "no README to name it" : named.length === names.length ? "named in the README: observed" : `named in the README: not observed for ${names.filter((name) => !named.includes(name)).join(", ")}`
328
+ return { sites: found.map((entry) => site(entry.row)), observation: `observed ${names.join(", ")} in argv (${sites(found.map((entry) => entry.row))}); ${readmeNote}` }
329
+ },
330
+ },
331
+ ])
332
+
333
+ /** The observation without its site lists and without the leading word, for a row whose sites are marked elsewhere. */
334
+ export function summaryOf(observation) {
335
+ const SITE = "[\\w./-]+:\\d+"
336
+ return observation
337
+ .replace(new RegExp(`;\\s*${SITE}(?:, ${SITE})*(?=\\))`, "g"), "")
338
+ .replace(new RegExp(`\\s*\\(${SITE}(?:, ${SITE})*\\)`, "g"), "")
339
+ .replace(/^observed /, "")
340
+ }
341
+
342
+ /**
343
+ * @param {{ processes, hosts, writes, timers, files, readme, baseline, blockingRules }} facts the observed facts of one document
344
+ * @returns {{ patterns: Array, lookedFor: string[] }}
345
+ */
346
+ export function evaluatePatterns(facts) {
347
+ const patterns = []
348
+ const lookedFor = []
349
+ for (const pattern of PATTERNS) {
350
+ const found = pattern.precondition(facts)
351
+ if (found.sites.length) patterns.push({ id: pattern.id, observedCount: found.sites.length, sites: found.sites, observation: found.observation, summary: summaryOf(found.observation), measurement: pattern.measurement, share: pattern.share })
352
+ else lookedFor.push(pattern.id)
353
+ }
354
+ return { patterns, lookedFor }
355
+ }
@@ -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
+ }