omakit 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/README.md +39 -32
  2. package/blocks/history.json +68 -0
  3. package/blocks/run/NOTICE +12 -0
  4. package/blocks/run/Run.qml +242 -0
  5. package/blocks/run/run-supervisor.py +522 -0
  6. package/blocks/store/NOTICE +12 -0
  7. package/blocks/store/Store.qml +157 -0
  8. package/blocks/store/store-helper.py +431 -0
  9. package/package.json +12 -5
  10. package/skills/omarchy-plugin-audit/SKILL.md +11 -5
  11. package/skills/omarchy-plugin-build/SKILL.md +164 -0
  12. package/skills/omarchy-plugin-check/SKILL.md +7 -4
  13. package/skills/omarchy-plugin-submit/SKILL.md +4 -1
  14. package/skills/omarchy-plugin-validation-watch/SKILL.md +5 -2
  15. package/skills/omarchy-plugin-weigh/SKILL.md +13 -2
  16. package/tests/fixtures/weigh/clean/Widget.qml +19 -0
  17. package/tests/fixtures/weigh/clean/manifest.json +9 -0
  18. package/tests/fixtures/weigh/clean/tests/harness.qml +7 -0
  19. package/tests/fixtures/weigh/idle-panel/Panel.qml +65 -0
  20. package/tests/fixtures/weigh/idle-panel/manifest.json +9 -0
  21. package/tests/fixtures/weigh/poller/Service.qml +50 -0
  22. package/tests/fixtures/weigh/poller/manifest.json +9 -0
  23. package/tests/fixtures/weigh/timer-180ms/Widget.qml +25 -0
  24. package/tests/fixtures/weigh/timer-180ms/manifest.json +9 -0
  25. package/tests/lab/run/harness/scenarios/controls.sh +6 -0
  26. package/tests/lab/run/harness/scenarios/envprobe.sh +10 -0
  27. package/tests/lab/run/harness/scenarios/forge.sh +11 -0
  28. package/tests/lab/run/harness/scenarios/holder.sh +5 -0
  29. package/tests/lab/run/harness/scenarios/orphan.sh +6 -0
  30. package/tests/lab/run/harness/scenarios/stall.sh +5 -0
  31. package/tests/lab/run/harness/scenarios/stubborn.sh +5 -0
  32. package/tests/lab/run/harness/scenarios/tree.sh +7 -0
  33. package/tests/lab/run/harness/shell.qml +84 -0
  34. package/tests/lab/run/report.py +217 -0
  35. package/tests/lab/run/suite.sh +106 -0
  36. package/tests/lab/store/harness/shell.qml +73 -0
  37. package/tests/lab/store/report.py +133 -0
  38. package/tests/lab/store/suite.sh +109 -0
  39. package/tests/parity/corpus.mjs +8 -3
  40. package/tests/parity/run.mjs +4 -4
  41. package/tools/audit/audit.mjs +17 -6
  42. package/tools/audit/git.mjs +3 -3
  43. package/tools/audit/report.mjs +31 -5
  44. package/tools/blocks/add.mjs +138 -0
  45. package/tools/blocks/commit.json +5 -0
  46. package/tools/blocks/record-commit.mjs +77 -0
  47. package/tools/blocks/registry.mjs +191 -0
  48. package/tools/blocks/stamp.mjs +61 -0
  49. package/tools/inspect/contract.mjs +37 -6
  50. package/tools/inspect/functions.mjs +236 -12
  51. package/tools/inspect/helpers.mjs +217 -0
  52. package/tools/inspect/inspect.mjs +71 -5
  53. package/tools/inspect/measure-functions.mjs +12 -3
  54. package/tools/inspect/patterns.mjs +35 -18
  55. package/tools/inspect/processes.mjs +38 -5
  56. package/tools/inspect/report.mjs +33 -6
  57. package/tools/inspect/writes.mjs +22 -4
  58. package/tools/lab/guest.mjs +155 -0
  59. package/tools/lab/harness.sh +119 -0
  60. package/tools/lab/host.mjs +177 -0
  61. package/tools/lab/inspect.mjs +240 -0
  62. package/tools/lab/omarchy.gpg +13 -0
  63. package/tools/lab/patches/omarchy-iso-test.patch +351 -0
  64. package/tools/lab/paths.mjs +173 -0
  65. package/tools/lab/pin.json +42 -0
  66. package/tools/lab/pin.mjs +64 -0
  67. package/tools/lab/prune.mjs +68 -0
  68. package/tools/lab/qemu.mjs +153 -0
  69. package/tools/lab/qmp-cli.mjs +21 -0
  70. package/tools/lab/report.mjs +183 -0
  71. package/tools/lab/run.mjs +344 -0
  72. package/tools/lab/setup.mjs +430 -0
  73. package/tools/lab/suites/run.sh +35 -0
  74. package/tools/lab/suites/store.sh +41 -0
  75. package/tools/lab/suites/weigh.sh +196 -0
  76. package/tools/lab/suites.mjs +142 -0
  77. package/tools/lab/verify.mjs +134 -0
  78. package/tools/marketplace/README.md +38 -1
  79. package/tools/marketplace/banner.mjs +23 -2
  80. package/tools/marketplace/cli.mjs +449 -146
  81. package/tools/marketplace/completion-check.mjs +27 -1
  82. package/tools/marketplace/completion.mjs +32 -4
  83. package/tools/marketplace/doctor.mjs +47 -9
  84. package/tools/marketplace/github.mjs +52 -6
  85. package/tools/marketplace/local-transport.mjs +1 -1
  86. package/tools/marketplace/options.mjs +16 -5
  87. package/tools/marketplace/outcome.mjs +244 -0
  88. package/tools/marketplace/pin.mjs +178 -33
  89. package/tools/marketplace/setup.mjs +16 -15
  90. package/tools/marketplace/tree.mjs +1 -1
  91. package/tools/marketplace/upgrade.mjs +5 -5
  92. package/tools/marketplace/usage.mjs +116 -72
  93. package/tools/subject/resolve.mjs +19 -6
  94. package/tools/weigh/audit.mjs +47 -16
  95. package/tools/weigh/config.mjs +105 -24
  96. package/tools/weigh/list.mjs +10 -1
@@ -0,0 +1,217 @@
1
+ // Which files of the tree a `Run {` site starts: the site's argv[0]
2
+ // resolved through the text to a path, never guessed from a base name. A
3
+ // path resolves when the text shows it: a string literal, a `+` chain of
4
+ // literals, `Qt.resolvedUrl("...")` (relative to the QML file) and
5
+ // `Quickshell.env("X")`; an identifier through its `const`, `let`, `var`
6
+ // or assignment in the enclosing text, a `property` binding, or the value
7
+ // a parent QML file binds when it instantiates the component; a call to a
8
+ // function the same file defines, through its return expression with the
9
+ // arguments in place of its parameters; `a ? b : c` through `b`, `a || b`
10
+ // through `a`, and `String()`, `decodeURIComponent()`, `.replace()`,
11
+ // `.trim()`, `.toString()` through their receiver. Anything else, a
12
+ // value from a JavaScript module included, is unresolved, and a helper
13
+ // whose path is unresolved is not read as started through Run. Measured
14
+ // before this: the rule matched a helper by base name in any string
15
+ // literal of the QML, so the plugin's own copy of a script Omarchy starts
16
+ // from its own tree read as Run-started.
17
+
18
+ import { basename, blankComments, closingBracket, propertyValue, blocks, stringLiteral } from "./text.mjs"
19
+
20
+ const DEPTH = 12
21
+ const TREE = "@/"
22
+
23
+ /** Split an expression at a top-level operator, ignoring strings and brackets. */
24
+ function splitTop(text, operator) {
25
+ const parts = []
26
+ let depth = 0
27
+ let quote = null
28
+ let start = 0
29
+ for (let index = 0; index < text.length; index += 1) {
30
+ const ch = text[index]
31
+ if (quote) {
32
+ if (ch === "\\") index += 1
33
+ else if (ch === quote) quote = null
34
+ continue
35
+ }
36
+ if (ch === '"' || ch === "'" || ch === "`") quote = ch
37
+ else if ("([{".includes(ch)) depth += 1
38
+ else if (")]}".includes(ch)) depth -= 1
39
+ else if (depth === 0 && text.startsWith(operator, index) && (operator !== "?" || text[index + 1] !== "?") && (operator !== "+" || text[index + 1] !== "+")) {
40
+ parts.push(text.slice(start, index))
41
+ index += operator.length - 1
42
+ start = index + 1
43
+ }
44
+ }
45
+ parts.push(text.slice(start))
46
+ return parts.map((part) => part.trim())
47
+ }
48
+
49
+ /** A normalised tree-relative path from a directory and a relative reference. */
50
+ function joinTree(dir, relative) {
51
+ const segments = []
52
+ for (const part of `${dir}/${relative}`.split("/")) {
53
+ if (part === "" || part === ".") continue
54
+ if (part === "..") segments.pop()
55
+ else segments.push(part)
56
+ }
57
+ return segments.join("/")
58
+ }
59
+
60
+ /** The definition of a function named `name` in `text`: its parameters and body. */
61
+ function functionIn(text, name) {
62
+ const match = new RegExp(`(?<![\\w.])function\\s+${name}\\s*\\(([^)]*)\\)\\s*\\{`).exec(text)
63
+ if (!match) return null
64
+ const open = match.index + match[0].length - 1
65
+ const end = closingBracket(text, open)
66
+ if (end < 0) return null
67
+ return { params: match[1].split(",").map((part) => part.trim()).filter(Boolean), body: text.slice(open + 1, end) }
68
+ }
69
+
70
+ /**
71
+ * The assignment or declaration of `name` in `text`: the first one, or,
72
+ * given a site offset `at`, the last one before the site (else the first
73
+ * after it: a property declared below its use), as expression text.
74
+ */
75
+ function assignmentIn(text, name, at = undefined) {
76
+ const patterns = [
77
+ new RegExp(`(?:const|let|var)\\s+${name}\\s*=\\s*([^\\n;]+)`, "g"),
78
+ new RegExp(`(?<![\\w.])(?:readonly\\s+)?property\\s+(?:string|var)\\s+${name}\\s*:\\s*([^\\n]+)`, "g"),
79
+ new RegExp(`(?<![\\w.])${name}\\s*=(?!=)\\s*([^\\n;]+)`, "g"),
80
+ ]
81
+ const found = patterns.flatMap((pattern) => [...text.matchAll(pattern)].map((match) => ({ index: match.index, text: match[1].trim() })))
82
+ if (!found.length) return null
83
+ const first = found.sort((a, b) => a.index - b.index)[0]
84
+ if (at === undefined) return first.text
85
+ const before = found.filter((entry) => entry.index < at).sort((a, b) => b.index - a.index)
86
+ return (before[0] || first).text
87
+ }
88
+
89
+ /** The value a parent QML file binds to `name` on an instance of `component`, with the file it sits in. */
90
+ function parentBinding(name, component, files) {
91
+ for (const file of files) {
92
+ if (file.kind !== "qml") continue
93
+ for (const block of blocks(file.text, component)) {
94
+ const value = propertyValue(block.body, name)
95
+ if (value && value.text.trim() !== '""') return { text: value.text.trim(), file }
96
+ }
97
+ }
98
+ return null
99
+ }
100
+
101
+ function resolveCall(callee, inner, scope) {
102
+ const args = splitTop(inner, ",")
103
+ if (callee === "Qt.resolvedUrl") {
104
+ const literal = stringLiteral(args[0] || "")
105
+ return literal === null ? null : `${TREE}${joinTree(scope.file.path.split("/").slice(0, -1).join("/"), literal)}`
106
+ }
107
+ if (callee === "Quickshell.env") {
108
+ const literal = stringLiteral(args[0] || "")
109
+ return literal === null ? null : `$${literal}`
110
+ }
111
+ if (["String", "decodeURIComponent", "encodeURIComponent"].includes(callee)) return resolve(args[0] || "", scope)
112
+ const name = callee.split(".").pop()
113
+ const fn = functionIn(scope.text, name) || functionIn(scope.file.text, name)
114
+ if (!fn) return null
115
+ const params = {}
116
+ fn.params.forEach((param, index) => { params[param] = { text: args[index] || '""', scope } })
117
+ const inner_scope = { ...scope, text: fn.body, params, depth: scope.depth + 1 }
118
+ for (const expression of returnsIn(fn.body)) {
119
+ const value = resolve(expression, inner_scope)
120
+ if (value !== null) return value
121
+ }
122
+ return null
123
+ }
124
+
125
+ /** Every `return` expression of a body, cut at a newline, a `;` or the `}` that closes an enclosing block. */
126
+ function returnsIn(body) {
127
+ const found = []
128
+ for (const match of body.matchAll(/(?<![\w.])return\s+/g)) {
129
+ let depth = 0
130
+ let quote = null
131
+ let end = body.length
132
+ for (let index = match.index + match[0].length; index < body.length; index += 1) {
133
+ const ch = body[index]
134
+ if (quote) {
135
+ if (ch === "\\") index += 1
136
+ else if (ch === quote) quote = null
137
+ continue
138
+ }
139
+ if (ch === '"' || ch === "'" || ch === "`") quote = ch
140
+ else if ("([{".includes(ch)) depth += 1
141
+ else if (")]}".includes(ch)) { if (depth === 0) { end = index; break } depth -= 1 }
142
+ else if ((ch === "\n" || ch === ";") && depth === 0) { end = index; break }
143
+ }
144
+ found.push(body.slice(match.index + match[0].length, end).trim())
145
+ }
146
+ return found
147
+ }
148
+
149
+ function resolveIdentifier(name, scope) {
150
+ if (scope.params[name]) return resolve(scope.params[name].text, scope.params[name].scope)
151
+ const inner = scope.text === scope.file.text ? null : assignmentIn(scope.text, name)
152
+ if (inner !== null && inner !== '""' && inner !== "''") return resolve(inner, { ...scope, depth: scope.depth + 1 })
153
+ const outer = assignmentIn(scope.file.text, name, scope.at)
154
+ if (outer !== null && outer !== '""' && outer !== "''") return resolve(outer, { ...scope, text: scope.file.text, params: {}, depth: scope.depth + 1 })
155
+ const component = basename(scope.file.path).replace(/\.qml$/, "")
156
+ const bound = parentBinding(name, component, scope.files)
157
+ if (!bound) return null
158
+ return resolve(bound.text, { file: bound.file, files: scope.files, text: bound.file.text, params: {}, depth: scope.depth + 1, at: Infinity })
159
+ }
160
+
161
+ /**
162
+ * @returns {string|null} a value: `@/tree/path` for a path under the tree, `$NAME...` for one built on an environment variable, a plain string for a literal; null when the text does not show it
163
+ */
164
+ export function resolve(text, scope) {
165
+ const expr = String(text || "").trim().replace(/;$/, "")
166
+ if (!expr || scope.depth > DEPTH) return null
167
+ const literal = stringLiteral(expr)
168
+ if (literal !== null) return literal
169
+ const ternary = splitTop(expr, "?")
170
+ if (ternary.length > 1) return resolve(splitTop(ternary.slice(1).join("?"), ":")[0], scope)
171
+ const either = splitTop(expr, "||")
172
+ if (either.length > 1) return resolve(either[0], scope)
173
+ const chain = splitTop(expr, "+")
174
+ if (chain.length > 1) {
175
+ const parts = chain.map((part) => resolve(part, scope))
176
+ return parts.some((part) => part === null) ? null : parts.join("")
177
+ }
178
+ if (expr.startsWith("(") && closingBracket(expr, 0) === expr.length - 1) return resolve(expr.slice(1, -1), scope)
179
+ const method = expr.match(/^(.+)\.(?:replace|trim|toString|toLowerCase|normalize)\s*\(/)
180
+ if (method && closingBracket(expr, expr.lastIndexOf("(", method[1].length + method[0].length)) === expr.length - 1) return resolve(method[1], scope)
181
+ const call = expr.match(/^([\w.]+)\s*\(/)
182
+ if (call && closingBracket(expr, call[0].length - 1) === expr.length - 1) return resolveCall(call[1], expr.slice(call[0].length, -1), scope)
183
+ if (expr === "Quickshell.shellDir") return "$QUICKSHELL_SHELL_DIR"
184
+ const identifier = expr.match(/^(?:root\.|this\.|[a-z][\w]*\.)?([A-Za-z_]\w*)$/)
185
+ if (identifier) return resolveIdentifier(identifier[1], scope)
186
+ return null
187
+ }
188
+
189
+ /**
190
+ * The tree files the QML starts through Run: for every Run site of every
191
+ * QML file, argv[0] resolved through the text; only a value under the
192
+ * tree that names a file of the tree counts. Sites whose argv[0] the text
193
+ * does not show resolve to nothing and mark nothing.
194
+ *
195
+ * @param {Array<{ path: string, kind: string, text: string }>} files
196
+ * @param {Array} processes the process rows, Run sites carrying block: "run"
197
+ * @returns {{ helpers: Set<string>, resolved: Map<string, string|null> }} helper paths, and per Run site (file:line) what argv[0] resolved to
198
+ */
199
+ export function runStartedHelpers(files, processes) {
200
+ const helpers = new Set()
201
+ const resolved = new Map()
202
+ const paths = new Set(files.map((file) => file.path))
203
+ // Comments are blanked first, as every extractor does: an apostrophe in
204
+ // a comment would otherwise open a string for the bracket matcher.
205
+ const blanked = files.map((file) => (file.kind === "qml" ? { ...file, text: blankComments(file.text) } : file))
206
+ for (const row of processes) {
207
+ if (row.declaredIn !== "qml" || row.block !== "run") continue
208
+ const file = blanked.find((entry) => entry.path === row.file)
209
+ const first = Array.isArray(row.argv) ? row.argv[0] : null
210
+ const at = file ? file.text.split("\n").slice(0, row.line).join("\n").length : 0
211
+ const value = file && first ? resolve(first, { file, files: blanked, text: file.text, params: {}, depth: 0, at }) : null
212
+ const path = value && value.startsWith(TREE) ? joinTree("", value.slice(TREE.length)) : null
213
+ resolved.set(`${row.file}:${row.line}`, path ? `${TREE}${path}` : value)
214
+ if (path && paths.has(path)) helpers.add(path)
215
+ }
216
+ return { helpers, resolved }
217
+ }
@@ -25,6 +25,8 @@ import { extractWrites } from "./writes.mjs"
25
25
  import { extractTimers } from "./timers.mjs"
26
26
  import { extractFunctions } from "./functions.mjs"
27
27
  import { evaluatePatterns, heavyShare, overSize, PATTERNS, rankOf, SIZE, sizeScore } from "./patterns.mjs"
28
+ import { recogniseBlockFile, shippedBlocks } from "../blocks/registry.mjs"
29
+ import { runStartedHelpers } from "./helpers.mjs"
28
30
 
29
31
  export const METHOD = "static extraction, regular expressions over qml and shell; observed, not executed"
30
32
 
@@ -43,7 +45,40 @@ export const NOT_VISIBLE = Object.freeze([
43
45
  ])
44
46
 
45
47
  /** The failure codes that mean the target could not be read at all: exit 2, the contract's second status. */
46
- export const NOT_READABLE = Object.freeze(["subject-not-found", "not-a-git-repository", "commit-not-found", "nothing-to-inspect", "usage"])
48
+ export const NOT_READABLE = Object.freeze(["subject-not-found", "not-a-directory", "not-a-git-repository", "commit-not-found", "nothing-to-inspect", "usage"])
49
+
50
+ /**
51
+ * The omakit blocks in the tree, read from the files' own headers and
52
+ * bodies (tools/blocks/registry.mjs): an unmodified copy of a shipped
53
+ * block is omakit's code, tested in this repository, and its lines are
54
+ * not extracted, so it raises no row of its own; a copy whose body is not
55
+ * one omakit shipped is reported as modified and read like any other file.
56
+ * A block is complete when every file the shipped block has is present.
57
+ *
58
+ * @returns {{ blocks: Array<{ name: string, version: string, shippedVersion: string|null, state: "unmodified"|"modified", complete: boolean, files: Array<{ path: string, state: "unmodified"|"modified", version: string }> }>, skip: Set<string> }}
59
+ */
60
+ export function recogniseBlocks(files) {
61
+ const shipped = shippedBlocks()
62
+ const found = new Map()
63
+ for (const file of files) {
64
+ const base = file.path.split("/").pop()
65
+ const seen = recogniseBlockFile(base, file.text, shipped)
66
+ if (!seen) continue
67
+ if (!found.has(seen.name)) found.set(seen.name, { name: seen.name, shippedVersion: seen.shippedVersion, files: [] })
68
+ found.get(seen.name).files.push({ path: file.path, state: seen.state, version: seen.version })
69
+ }
70
+ const blocks = []
71
+ const skip = new Set()
72
+ for (const entry of [...found.values()].sort((a, b) => (a.name < b.name ? -1 : 1))) {
73
+ const expected = shipped.find((block) => block.name === entry.name)?.files.map((file) => file.file) || []
74
+ const complete = expected.length > 0 && expected.every((name) => entry.files.some((file) => file.path.split("/").pop() === name))
75
+ const state = entry.files.every((file) => file.state === "unmodified") ? "unmodified" : "modified"
76
+ const versions = [...new Set(entry.files.map((file) => file.version))]
77
+ for (const file of entry.files) if (file.state === "unmodified") skip.add(file.path)
78
+ blocks.push({ name: entry.name, version: versions.length === 1 ? versions[0] : versions.join(", "), shippedVersion: entry.shippedVersion, state, complete, files: entry.files.sort((a, b) => (a.path < b.path ? -1 : 1)) })
79
+ }
80
+ return { blocks, skip }
81
+ }
47
82
 
48
83
  export class InspectError extends Error {
49
84
  constructor(code, message, remedy = null) {
@@ -78,6 +113,11 @@ export async function inspectPlugin({ repoRoot, target, offline = false, allowDi
78
113
  const pluginId = typeof tree.manifest?.id === "string" ? tree.manifest.id.trim() : null
79
114
 
80
115
  onPhase("reading processes, hosts, writes and timers")
116
+ const { blocks, skip } = recogniseBlocks(tree.files)
117
+ // A `Run {` site is a process only where the tree carries the run block
118
+ // whole and unmodified; otherwise the name is the plugin's own.
119
+ const runBlock = blocks.some((block) => block.name === "run" && block.state === "unmodified" && block.complete)
120
+ const storeBlock = runBlock && blocks.some((block) => block.name === "store" && block.state === "unmodified" && block.complete)
81
121
  const processes = []
82
122
  const hosts = []
83
123
  const writes = []
@@ -85,9 +125,10 @@ export async function inspectPlugin({ repoRoot, target, offline = false, allowDi
85
125
  const functions = []
86
126
  const notResolvable = []
87
127
  for (const file of tree.files) {
128
+ if (skip.has(file.path)) continue
88
129
  // Each function carries its rank among the listed ones (M12).
89
130
  functions.push(...extractFunctions(file).map((entry) => ({ ...entry, percentile: rankOf(entry) })))
90
- const rows = extractProcesses(file)
131
+ const rows = extractProcesses(file, { runBlock })
91
132
  processes.push(...rows)
92
133
  for (const row of rows) {
93
134
  if (row.argvForm === "computed") notResolvable.push({ file: row.file, line: row.line, kind: "command", text: `command: ${row.commandText}` })
@@ -95,12 +136,32 @@ export async function inspectPlugin({ repoRoot, target, offline = false, allowDi
95
136
  const found = extractHosts(file, rows)
96
137
  hosts.push(...found.hosts)
97
138
  notResolvable.push(...found.notResolvable)
98
- writes.push(...extractWrites(file, { pluginId }))
139
+ writes.push(...extractWrites(file, { pluginId, storeBlock }))
99
140
  const ticking = extractTimers(file)
100
141
  timers.push(...ticking.timers)
101
142
  notResolvable.push(...ticking.notResolvable)
102
143
  }
103
144
 
145
+ // A helper the QML starts through Run runs in the block's closed
146
+ // environment (PATH=/usr/bin and the named variables, docs/BLOCKS.md),
147
+ // so a bare tool name inside it is not an ambient PATH lookup. A helper
148
+ // is one a Run site's argv[0] resolves to through the text
149
+ // (helpers.mjs): a path the text does not show marks nothing, and only
150
+ // when every QML process site of the tree is a Run site are its shell
151
+ // lines marked closedEnvironment. The hook Omarchy runs, a test script,
152
+ // a helper reached through a value the text does not show: ambient.
153
+ const qmlSites = processes.filter((row) => row.declaredIn === "qml")
154
+ const allRun = qmlSites.length > 0 && qmlSites.every((row) => row.block === "run")
155
+ const started = runStartedHelpers(tree.files, processes)
156
+ const closed = allRun ? started.helpers : new Set()
157
+ for (const row of processes) {
158
+ row.closedEnvironment = row.declaredIn === "shell" && closed.has(row.file)
159
+ if (row.block === "run") {
160
+ const value = started.resolved.get(`${row.file}:${row.line}`)
161
+ row.helper = value && value.startsWith("@/") && started.helpers.has(value.slice(2)) ? value.slice(2) : null
162
+ }
163
+ }
164
+
104
165
  let marketplaceBaseline
105
166
  let blockingRules = []
106
167
  if (offline) {
@@ -138,13 +199,18 @@ export async function inspectPlugin({ repoRoot, target, offline = false, allowDi
138
199
  uncommittedFiles: subject.uncommittedFiles,
139
200
  },
140
201
  observed: { processes, hosts, writes, timers, functions },
202
+ // The omakit blocks in the tree, by their headers and body hashes: an
203
+ // unmodified block's files were not read for facts (they are omakit's,
204
+ // tested here), a modified one's were.
205
+ blocks,
141
206
  // Size: the functions over the M12 thresholds, longest first. A count of
142
207
  // lines, branches and nesting over the text, compared with what 90 of
143
208
  // 100 functions in listed trees stay under; never a judgement.
144
209
  size: {
145
210
  measurement: SIZE.measurement,
146
- // The listed trees' own heavy shares, so the score can be read from
147
- // the document alone: `heavyShares[i]` is the i-th listed tree's.
211
+ // The listed trees' own heavy shares, in the record's row order and
212
+ // only for the trees with a function, so the score can be read from
213
+ // the document alone.
148
214
  sample: { trees: SIZE.trees, functions: SIZE.functions, heavyShares: [...SIZE.distribution.heavyShare] },
149
215
  thresholds: { lines: SIZE.lines, branches: SIZE.branches, depth: SIZE.depth },
150
216
  // The share of this tree's function lines inside functions over the
@@ -98,14 +98,23 @@ export function buildRecord(trees, { fetchFailed = [], date = new Date().toISOSt
98
98
  medianLines: median(tree.functions.map((row) => row.lines)),
99
99
  functionLines,
100
100
  heavyLines,
101
- heavyShare: functionLines ? Math.round((heavyLines / functionLines) * 10000) / 10000 : 0,
101
+ // null, not 0, with no function: a tree with nothing to measure did not measure light.
102
+ heavyShare: functionLines ? Math.round((heavyLines / functionLines) * 10000) / 10000 : null,
102
103
  }
103
104
  })
104
105
  return {
105
106
  measurement: "M12",
106
107
  date,
107
108
  command: "node tools/inspect/measure-functions.mjs",
108
- method: `extractFunctions from tools/inspect/functions.mjs over the first ${trees.length} distinct repositories in the pinned catalog's order whose listing is community, laid out as a root plugin and carrying a validated commit (the 2026-09-15 inspect record's rule), each fetched read-only at that commit in reviewer mode. A function is a \`function name(\`, a named arrow function (\`const load = (rows) => {\`, \`this.load = rows => {\`), a method shorthand \`load(rows) {\` inside an object literal or a class, or a multi-line \`onSomething: {\` handler in QML and JavaScript; a \`name() {\` or \`function name\` block in shell; a \`def\` in Python. Anonymous callbacks are not counted. Lines are first to last line inclusive; depth is the deepest nesting below the body, the body itself at 0 in every language, a brace that opens an object or array literal or an inline arrow body not a level; branches count if, else if, for, while, switch, case, catch, &&, || and ?: (their shell and Python equivalents). Quantiles are nearest-rank over every function in the sample pooled, not per tree. \`distribution\` is the histogram of each measure over the same functions, value to count, from which a function's percentile rank is read. Per tree, functionLines is the lines inside every function, heavyLines the lines inside functions over any p90 threshold of this record, and heavyShare their ratio, 0 with no function; the size score of omakit inspect is a tree's position among these 50 heavyShares.`,
109
+ method: [
110
+ `extractFunctions from tools/inspect/functions.mjs over the first ${trees.length} distinct repositories in the pinned catalog's order whose listing is community, laid out as a root plugin and carrying a validated commit (the 2026-09-15 inspect record's rule), each fetched read-only at that commit in reviewer mode.`,
111
+ "A function is a `function name(`, a named arrow function (`const load = (rows) => {`, `this.load = rows => {`), a method shorthand `load(rows) {` inside an object literal or a class, or a multi-line `onSomething: {` handler in QML and JavaScript; a `name() {` or `function name` block in shell; a `def` in Python. Anonymous callbacks are not counted.",
112
+ "Lines are first to last line inclusive; depth is the deepest nesting below the body, the body itself at 0 in every language, a brace that opens an object or array literal or an inline arrow body not a level; branches count if, else if, for, while, switch, case, catch, &&, || and ?: (their shell and Python equivalents).",
113
+ "In shell, a `||` or `&&` followed by one flow word (return, exit, continue, break, true, false, :) with an optional status (a number, `$?` or a variable) and nothing else on the line but a `;` or `;;` is a guard and not a branch, one per line at its end, so `x && return 0 || return 1` keeps its `&&`; the body of a heredoc (`<<WORD`, `<<-WORD`, quoted or backslashed, outside quotes and outside arithmetic) up to its delimiter alone on a line, and a quoted string that spans lines from its opening quote to the line that closes it, count toward the length and toward nothing else, while a `$(...)`, `${...}` or backtick substitution nested in a string reads its own quotes and, spanning lines, is shell and read as shell; a case arm is a `)` with text after it on a line with no `(` before it but its own opening one; a block opens with `if`, `for`, `while`, `until`, `case` or `select` and closes with `fi`, `done` or `esac`, each counted only where a statement can start over the line with its quoted text removed, so a block that opens and closes on one line is no level and a keyword inside a string or as an argument is nothing.",
114
+ "In Python, a line that starts while a bracket is open, inside a triple-quoted string, or after a line ending in a backslash is a continuation of the statement above it, and a def's own parameter list spanning lines is a continuation of the def: it counts toward the length and the branches and never toward nesting; a bracket or backslash continuation ends at the first line at the def's indent that is not a closing bracket, a triple-quoted string at its close. Branch words inside strings, docstrings and comments are prose, not branches, and a def quoted in a docstring is not a function.",
115
+ "Quantiles are nearest-rank over every function in the sample pooled, not per tree. `distribution` is the histogram of each measure over the same functions, value to count, from which a function's percentile rank is read.",
116
+ "Per tree, functionLines is the lines inside every function, heavyLines the lines inside functions over any p90 threshold of this record, and heavyShare their ratio, null with no function; the size score of omakit inspect is a tree's position among the non-null heavyShares.",
117
+ ].join(" "),
109
118
  sample: { trees: trees.length, functions: pooled.length, fetchFailed },
110
119
  quantiles,
111
120
  distribution: { lines: histogram(lines), branches: histogram(branches), depth: histogram(depth) },
@@ -114,7 +123,7 @@ export function buildRecord(trees, { fetchFailed = [], date = new Date().toISOSt
114
123
  }
115
124
  }
116
125
 
117
- const PREVIOUS = "Re-measured on 2026-09-16 after three extraction fixes, so the quantiles are not comparable with the 0.4.3 record (this file at commit 6619c26: 50 trees, 6040 functions, p90 22 lines, 6 branches, nesting 3; and before it 18 trees, 715 functions, p90 18 lines, 7 branches, nesting 2): a Python body now starts at depth 0 the way a brace body does, where it started at 1; a brace that opens an object or array literal is no longer a nesting level; and a named arrow function or a method shorthand is a function, where only `function name(` and `onSomething: {` were."
126
+ const PREVIOUS = "Re-measured on 2026-09-16 after three extraction fixes, so the quantiles are not comparable with the 0.4.3 record (this file at commit 6619c26: 50 trees, 6040 functions, p90 22 lines, 6 branches, nesting 3; and before it 18 trees, 715 functions, p90 18 lines, 7 branches, nesting 2): a Python body now starts at depth 0 the way a brace body does, where it started at 1; a brace that opens an object or array literal is no longer a nesting level; and a named arrow function or a method shorthand is a function, where only `function name(` and `onSomething: {` were. Re-measured again for 0.5.1 after more (the 0.5.0 record is this file at commit 7df451a: 6041 functions, p90 22 lines, 6 branches, nesting 2, six heavyShares of 0 among 50): a Python continuation line inside an open bracket, a triple-quoted string or after a backslash no longer counts as nesting, and a def whose parameter list spans lines is read through to its body where before only the signature was, which is what moved the line p90 from 22 to 25; branch words in Python strings and docstrings are prose; a shell heredoc body and a quoted program spanning lines are no longer read as shell; a shell guard (`|| return 1`) is no longer a branch; and a tree with no function carries heavyShare null instead of 0, so it no longer lifts every other tree's rank. The earlier 0.5.1 cuts, never released, are in this file's history, each corrected by the next: one read a `\"` inside a string and a `\"\"\"` inside a bracket wrongly and stretched two listed Python functions (101 to 133 and 128 to 162 lines); one still read `<<` in arithmetic and a quote inside `$(...)` inside a string wrongly, and counted docstring prose as branches; one counted `if x; then y; fi` on one line as a nesting level; and one closed a block on `done` as a plain argument and peeled two guards from one line."
118
127
 
119
128
  export async function measureFunctions(repoRoot, { count = TREES, cacheRoot = omakitCacheDir(), log = () => {} } = {}) {
120
129
  const pin = requirePin(repoRoot)
@@ -34,11 +34,20 @@ function hasFlag(flags, letter, long = null) {
34
34
  return flags.some((flag) => flag === `-${letter}` || (long && (flag === long || flag.startsWith(`${long}=`))) || (/^-[A-Za-z]+$/.test(flag) && flag.includes(letter)))
35
35
  }
36
36
 
37
- /** The tool a process resolves through PATH: its first non-wrapper word when that word has no slash. */
37
+ /**
38
+ * The tool a process resolves through PATH: its first non-wrapper word
39
+ * when that word is a literal with no slash. A word that is an expression
40
+ * (`[script, name]`, `[root.helperPath("x.sh")]`) is not a name looked up
41
+ * in PATH; it is a value the text does not show, and the argument-grammar
42
+ * class already names the site. Measured on the Theme Manager port
43
+ * (2026-09-17): 12 of its 24 Run sites read as "resolved from PATH" with
44
+ * the tool `script`, an absolute path at run time.
45
+ */
38
46
  function pathResolved(process) {
39
47
  if (!Array.isArray(process.argv) || !process.argv.length) return null
40
- const { tool } = toolOf(process.argv)
48
+ const { tool, index } = toolOf(process.argv)
41
49
  if (!tool || tool.includes("/")) return null
50
+ if ((process.expressions || []).some((entry) => entry.index === index)) return null
42
51
  return tool
43
52
  }
44
53
 
@@ -89,22 +98,23 @@ function secretLogs(files) {
89
98
  */
90
99
  export const SIZE = Object.freeze({
91
100
  measurement: "M12",
92
- sample: "50 listed trees, 6041 functions",
101
+ sample: "50 listed trees, 6034 functions",
93
102
  trees: 50,
94
- functions: 6041,
95
- lines: 22,
103
+ functions: 6034,
104
+ lines: 25,
96
105
  branches: 6,
97
106
  depth: 2,
98
- // The histogram of each measure over the 6041 functions, value to count,
107
+ // The histogram of each measure over the 6034 functions, value to count,
99
108
  // from which a function's percentile rank among listed functions is read;
100
109
  // 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.
110
+ // functions over the thresholds, in the record's row order and only for
111
+ // the trees with a function (a tree with nothing to measure has no
112
+ // share), from which a tree's position among listed trees is read.
103
113
  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]),
114
+ lines: Object.freeze({ 1: 330, 2: 157, 3: 667, 4: 620, 5: 584, 6: 508, 7: 413, 8: 337, 9: 301, 10: 238, 11: 195, 12: 138, 13: 152, 14: 131, 15: 106, 16: 75, 17: 86, 18: 62, 19: 71, 20: 57, 21: 51, 22: 56, 23: 47, 24: 44, 25: 33, 26: 28, 27: 24, 28: 26, 29: 27, 30: 17, 31: 21, 32: 22, 33: 18, 34: 23, 35: 11, 36: 5, 37: 16, 38: 13, 39: 13, 40: 9, 41: 13, 42: 12, 43: 11, 44: 9, 45: 8, 46: 14, 47: 8, 48: 11, 49: 7, 50: 6, 51: 1, 52: 2, 53: 6, 54: 3, 55: 7, 56: 4, 57: 3, 58: 5, 59: 3, 60: 8, 61: 5, 62: 3, 63: 6, 64: 2, 65: 3, 66: 1, 67: 3, 68: 4, 69: 9, 70: 7, 71: 7, 72: 3, 73: 3, 74: 3, 75: 1, 76: 1, 77: 2, 78: 2, 79: 3, 80: 3, 81: 4, 82: 3, 83: 1, 85: 1, 86: 3, 87: 3, 88: 1, 89: 1, 90: 3, 91: 2, 92: 2, 93: 2, 94: 1, 96: 3, 97: 2, 98: 1, 99: 1, 100: 1, 101: 3, 102: 2, 104: 2, 107: 2, 108: 1, 109: 2, 110: 2, 111: 2, 112: 2, 113: 2, 115: 3, 116: 1, 117: 2, 119: 1, 120: 1, 121: 1, 125: 1, 126: 1, 128: 2, 129: 1, 133: 1, 134: 1, 142: 1, 144: 1, 145: 1, 154: 1, 155: 1, 161: 2, 173: 1, 217: 2, 253: 1, 272: 1, 277: 1, 290: 1, 292: 1, 298: 1, 325: 1, 365: 1, 495: 1 }),
115
+ branches: Object.freeze({ 0: 2302, 1: 1130, 2: 828, 3: 457, 4: 344, 5: 221, 6: 180, 7: 127, 8: 83, 9: 70, 10: 48, 11: 48, 12: 24, 13: 30, 14: 20, 15: 10, 16: 14, 17: 10, 18: 12, 19: 8, 20: 6, 21: 4, 22: 5, 23: 5, 24: 7, 25: 4, 26: 2, 27: 7, 28: 5, 29: 2, 31: 1, 32: 2, 33: 2, 35: 1, 36: 3, 38: 1, 39: 1, 40: 1, 41: 1, 43: 1, 45: 2, 55: 1, 64: 1, 75: 1, 78: 1, 85: 1 }),
116
+ depth: Object.freeze({ 0: 3854, 1: 1478, 2: 482, 3: 140, 4: 47, 5: 27, 6: 5, 7: 1 }),
117
+ heavyShare: Object.freeze([0.421, 0.3361, 0.4049, 0, 0.2979, 0.3647, 0.4107, 0.6167, 0.2635, 0.8434, 0, 0, 0.193, 0.6399, 0.4063, 0.4769, 0.4322, 0.5337, 0.3628, 0.6626, 0.0554, 0.7775, 0.3548, 0.3285, 0, 0.6801, 0.2222, 0.3084, 0.3705, 0.6468, 0.3928, 0.6786, 0.3247, 0.5225, 0, 0.1462, 0, 0.5817, 0.5891, 0.3835, 0.2491, 0.5627, 0.2439, 0.3643, 0.6719, 0.6525, 0.4385, 0.565, 0.5672]),
108
118
  }),
109
119
  })
110
120
 
@@ -142,13 +152,14 @@ export function heavyShare(functions) {
142
152
 
143
153
  /**
144
154
  * 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.
155
+ * one, as a percentage over the listed trees that have a share: 0 for a
156
+ * tree with no function over the thresholds, 100 for one heavier than
157
+ * every listed tree.
148
158
  */
149
159
  export function treeRank(share) {
150
- const below = SIZE.distribution.heavyShare.filter((listed) => listed < share).length
151
- return Math.round((below / SIZE.trees) * 1000) / 10
160
+ const shares = SIZE.distribution.heavyShare
161
+ const below = shares.filter((listed) => listed < share).length
162
+ return Math.round((below / shares.length) * 1000) / 10
152
163
  }
153
164
 
154
165
  /**
@@ -220,13 +231,19 @@ export const PATTERNS = Object.freeze([
220
231
  share: 0.07,
221
232
  sample: SAMPLE,
222
233
  precondition: ({ processes, hosts }) => {
223
- const resolved = processes.map((row) => ({ row, tool: pathResolved(row) })).filter((entry) => entry.tool)
234
+ // A line of a helper started through Run resolves its tools in the
235
+ // block's closed PATH (docs/BLOCKS.md); it is counted apart, never as
236
+ // an ambient lookup.
237
+ const ambient = processes.filter((row) => !row.closedEnvironment)
238
+ const resolved = ambient.map((row) => ({ row, tool: pathResolved(row) })).filter((entry) => entry.tool)
239
+ const closed = processes.filter((row) => row.closedEnvironment && pathResolved(row))
224
240
  const curls = hosts.filter((row) => row.tool === "curl" && !hasFlag(row.flags, "q", "--disable"))
225
241
  const parts = []
226
242
  const names = [...new Set(resolved.map((entry) => entry.tool))]
227
243
  const named = names.length > 6 ? `${names.slice(0, 6).join(", ")} and ${names.length - 6} more` : names.join(", ")
228
244
  if (resolved.length) parts.push(`${plural(resolved.length, "tool")} resolved from PATH (${named}; ${sites(resolved.map((entry) => entry.row))})`)
229
245
  if (curls.length) parts.push(`curl without -q (${sites(curls)})`)
246
+ if (closed.length) parts.push(`${plural(closed.length, "tool name")} in ${plural(new Set(closed.map((row) => row.file)).size, "helper")} started through Run, resolved in the block's closed PATH and not counted`)
230
247
  return { sites: [...resolved.map((entry) => entry.row), ...curls].map(site), observation: `observed ${parts.join("; ")}` }
231
248
  },
232
249
  },
@@ -1,5 +1,7 @@
1
1
  // Process sites: every `Process {` block in QML (its `command:` inside the
2
- // block or assigned to its id elsewhere in the file), every
2
+ // block or assigned to its id elsewhere in the file), every `Run {` block
3
+ // where the tree carries the omakit run block unmodified (its deadline and
4
+ // caps are the block's, docs/BLOCKS.md), every
3
5
  // `Quickshell.execDetached([...])`, and every command line of a shell
4
6
  // script. Each row carries the argv the text shows, whether a deadline is
5
7
  // observed for it, what collects its output and whether a producer-side
@@ -167,6 +169,7 @@ function qmlRow(file, text, block, command, commandOffset) {
167
169
  deadline: deadlineFor(text, id, resolved.argv),
168
170
  output: outputOf(block.body, resolved.argv),
169
171
  shellWrapper: isShellWrapper(resolved.argv),
172
+ closedEnvironment: false,
170
173
  pipedFrom: null,
171
174
  }
172
175
  }
@@ -195,16 +198,42 @@ function detachedRows(file, text) {
195
198
  deadline: { observed: false, via: null, ms: null },
196
199
  output: { collector: "none", capObserved: false, via: null },
197
200
  shellWrapper: isShellWrapper(resolved.argv),
201
+ closedEnvironment: false,
198
202
  pipedFrom: null,
199
203
  })
200
204
  }
201
205
  return rows
202
206
  }
203
207
 
204
- /** Every `Process {` block and detached exec in a QML file. */
205
- export function qmlProcesses(file) {
208
+ /**
209
+ * A `Run {` site of the omakit run block: the deadline is the block's
210
+ * (`deadlineMs`, default 10000), the caps are the block's (`maxBytes`),
211
+ * and a shell string is refused unless `allowShellString: true` is on the
212
+ * block's own line, in which case the wrapper is observed as it would be
213
+ * on a Process.
214
+ */
215
+ function runRow(file, text, block) {
216
+ const inside = propertyValue(block.body, "command")
217
+ const command = inside && !/^\[\s*\]$/.test(inside.text) ? inside : assignedCommand(text, block.id)
218
+ const offset = inside && !/^\[\s*\]$/.test(inside.text) ? block.open + 1 + inside.offset : command ? command.offset : null
219
+ const row = qmlRow(file, text, block, command, offset)
220
+ const deadline = numeric(propertyValue(block.body, "deadlineMs")?.text)
221
+ const allowShellString = propertyValue(block.body, "allowShellString")?.text === "true"
222
+ return {
223
+ ...row,
224
+ running: row.running || (block.id ? new RegExp(`(?<![\\w.])${block.id}\\.start\\s*\\(`).test(text) : false),
225
+ block: "run",
226
+ deadline: { observed: true, via: "block-run", ms: deadline ?? 10000 },
227
+ output: { collector: "Run", capObserved: true, via: "maxBytes" },
228
+ shellWrapper: allowShellString ? row.shellWrapper : false,
229
+ }
230
+ }
231
+
232
+ /** Every `Process {` block and detached exec in a QML file; `Run {` blocks too when the tree carries the run block. */
233
+ export function qmlProcesses(file, { runBlock = false } = {}) {
206
234
  const text = blankComments(file.text)
207
235
  const rows = []
236
+ if (runBlock) for (const block of blocks(text, "Run")) rows.push(runRow(file, text, block))
208
237
  for (const block of blocks(text, "Process")) {
209
238
  const inside = propertyValue(block.body, "command")
210
239
  // `command: []` is a placeholder for a value set later: the assignment to
@@ -235,6 +264,9 @@ function commandWords(segment) {
235
264
  if (words[0] === "for" || words[0] === "case" || words[0] === "select" || words[0] === "function") return null
236
265
  words = words.slice(1)
237
266
  }
267
+ // `exec cmd` replaces the shell with cmd: a process site, the wrapper
268
+ // dropped; `exec` alone or with only redirections runs nothing.
269
+ while (words.length > 1 && words[0] === "exec" && !/^[<>&\d]/.test(words[1])) words = words.slice(1)
238
270
  if (!words.length) return null
239
271
  if (/^[A-Za-z_]\w*\(\)$/.test(words[0])) return null
240
272
  if (BUILTINS.has(basename(words[0])) && basename(words[0]) !== "eval") return null
@@ -278,6 +310,7 @@ export function shellProcesses(file) {
278
310
  deadline: stripped.some((word) => basename(word) === "timeout") ? { observed: true, via: "timeout-argv", ms: deadlineMs } : { observed: false, via: null, ms: null },
279
311
  output: { collector: "none", capObserved: lineCap.observed, via: lineCap.via },
280
312
  shellWrapper: isShellWrapper(stripped),
313
+ closedEnvironment: false,
281
314
  pipedFrom: segment.operator === "|" || segment.operator === "|&" ? previous : null,
282
315
  }
283
316
  rows.push(row)
@@ -291,8 +324,8 @@ export function shellProcesses(file) {
291
324
  * @param {{ path: string, kind: string, text: string }} file
292
325
  * @returns {Array} process rows, in file order
293
326
  */
294
- export function extractProcesses(file) {
295
- if (file.kind === "qml") return qmlProcesses(file)
327
+ export function extractProcesses(file, options = {}) {
328
+ if (file.kind === "qml") return qmlProcesses(file, options)
296
329
  if (file.kind === "shell") return shellProcesses(file)
297
330
  return []
298
331
  }