omakit 0.5.1 → 0.6.2

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 (94) hide show
  1. package/README.md +38 -45
  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 +6 -3
  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 +36 -5
  50. package/tools/inspect/helpers.mjs +217 -0
  51. package/tools/inspect/inspect.mjs +68 -3
  52. package/tools/inspect/patterns.mjs +18 -3
  53. package/tools/inspect/processes.mjs +38 -5
  54. package/tools/inspect/report.mjs +18 -2
  55. package/tools/inspect/writes.mjs +22 -4
  56. package/tools/lab/guest.mjs +155 -0
  57. package/tools/lab/harness.sh +119 -0
  58. package/tools/lab/host.mjs +177 -0
  59. package/tools/lab/inspect.mjs +240 -0
  60. package/tools/lab/omarchy.gpg +13 -0
  61. package/tools/lab/patches/omarchy-iso-test.patch +351 -0
  62. package/tools/lab/paths.mjs +173 -0
  63. package/tools/lab/pin.json +42 -0
  64. package/tools/lab/pin.mjs +64 -0
  65. package/tools/lab/prune.mjs +68 -0
  66. package/tools/lab/qemu.mjs +153 -0
  67. package/tools/lab/qmp-cli.mjs +21 -0
  68. package/tools/lab/report.mjs +183 -0
  69. package/tools/lab/run.mjs +344 -0
  70. package/tools/lab/setup.mjs +430 -0
  71. package/tools/lab/suites/run.sh +35 -0
  72. package/tools/lab/suites/store.sh +41 -0
  73. package/tools/lab/suites/weigh.sh +196 -0
  74. package/tools/lab/suites.mjs +142 -0
  75. package/tools/lab/verify.mjs +134 -0
  76. package/tools/marketplace/README.md +38 -1
  77. package/tools/marketplace/banner.mjs +23 -2
  78. package/tools/marketplace/cli.mjs +449 -146
  79. package/tools/marketplace/completion-check.mjs +27 -1
  80. package/tools/marketplace/completion.mjs +32 -4
  81. package/tools/marketplace/doctor.mjs +47 -9
  82. package/tools/marketplace/github.mjs +52 -6
  83. package/tools/marketplace/local-transport.mjs +1 -1
  84. package/tools/marketplace/options.mjs +16 -5
  85. package/tools/marketplace/outcome.mjs +244 -0
  86. package/tools/marketplace/pin.mjs +178 -33
  87. package/tools/marketplace/setup.mjs +16 -15
  88. package/tools/marketplace/tree.mjs +1 -1
  89. package/tools/marketplace/upgrade.mjs +5 -5
  90. package/tools/marketplace/usage.mjs +116 -72
  91. package/tools/subject/resolve.mjs +19 -6
  92. package/tools/weigh/audit.mjs +47 -16
  93. package/tools/weigh/config.mjs +105 -24
  94. package/tools/weigh/list.mjs +10 -1
@@ -0,0 +1,191 @@
1
+ // The blocks omakit ships, read from blocks/<name>/ in this checkout or
2
+ // package: which files each has, their headers, and the sha256 of every
3
+ // file's body, which is what `omakit add` writes, what it refuses to
4
+ // overwrite when it differs, and what `omakit inspect` recognises an
5
+ // unmodified copy by. Nothing here is a marketplace rule (AGENTS.md, rule
6
+ // 2): a block is behaviour measured from public review comments (M13,
7
+ // docs/BLOCKS.md), and this module only knows the block's own files.
8
+ //
9
+ // A file's header is its leading comment lines up to and including the
10
+ // line `end of omakit block header`; the body is everything after that
11
+ // line. The header names the block and its version, the licence, the
12
+ // copyright, where the file came from (the commit is stamped by `add`,
13
+ // so it is the one line a copy may differ from the shipped file in) and
14
+ // the body's sha256, so a person can check a copy with sha256sum alone.
15
+
16
+ import { createHash } from "node:crypto"
17
+ import { readdirSync, readFileSync } from "node:fs"
18
+ import { dirname, join, resolve } from "node:path"
19
+ import { fileURLToPath } from "node:url"
20
+
21
+ export const BLOCKS_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "../../blocks")
22
+
23
+ export const HEADER_END = "end of omakit block header"
24
+
25
+ /**
26
+ * A block that uses another block's files from the same omakit/ directory:
27
+ * `omakit add` writes the required block too, and inspect reads the
28
+ * requirement when it says whether a block is complete.
29
+ */
30
+ export const REQUIRES = Object.freeze({ store: Object.freeze(["run"]) })
31
+
32
+ /** The blocks to add for one name: its requirements first, then itself, each once. */
33
+ export function blockClosure(name) {
34
+ const names = []
35
+ for (const required of REQUIRES[name] || []) for (const entry of blockClosure(required)) if (!names.includes(entry)) names.push(entry)
36
+ if (!names.includes(name)) names.push(name)
37
+ return names
38
+ }
39
+
40
+ /**
41
+ * Split a block file's text into its header lines and its body. Null when
42
+ * the text has no block header, which is how a plugin's own file reads.
43
+ *
44
+ * @param {string} text
45
+ * @returns {{ header: string[], body: string, name: string, version: string, sha256: string|null } | null}
46
+ */
47
+ export function parseHeader(text) {
48
+ const lines = String(text).split("\n")
49
+ const end = lines.findIndex((line) => line.replace(/^(?:\/\/|#)\s*/, "").trim() === HEADER_END)
50
+ if (end < 0 || end > 12) return null
51
+ const header = lines.slice(0, end + 1)
52
+ const strip = (line) => line.replace(/^(?:\/\/|#)\s?/, "")
53
+ const named = header.map(strip).find((line) => /^omakit block: /.test(line))
54
+ if (!named) return null
55
+ const match = named.match(/^omakit block: ([a-z][a-z0-9-]*) (\d+\.\d+\.\d+)$/)
56
+ if (!match) return null
57
+ const sha = header.map(strip).find((line) => /^Body sha256: /.test(line))?.slice("Body sha256: ".length).trim() || null
58
+ return { header, body: lines.slice(end + 1).join("\n"), name: match[1], version: match[2], sha256: sha && /^[0-9a-f]{64}$/.test(sha) ? sha : null }
59
+ }
60
+
61
+ export function bodySha256(body) {
62
+ return createHash("sha256").update(body, "utf8").digest("hex")
63
+ }
64
+
65
+ /**
66
+ * Every shipped block: its name, version, and files with their parsed
67
+ * headers and body hashes, from blocks/<name>/ (NOTICE is the listing, not
68
+ * a block file, and has no header).
69
+ *
70
+ * @param {string} [dir]
71
+ * @returns {Array<{ name: string, version: string, dir: string, files: Array<{ file: string, text: string, header: string[], body: string, sha256: string, headerSha256: string|null }>, notice: string }>}
72
+ */
73
+ export function shippedBlocks(dir = BLOCKS_DIR) {
74
+ const blocks = []
75
+ for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : 1))) {
76
+ if (!entry.isDirectory()) continue
77
+ const blockDir = join(dir, entry.name)
78
+ const files = []
79
+ let notice = ""
80
+ for (const file of readdirSync(blockDir).sort()) {
81
+ const text = readFileSync(join(blockDir, file), "utf8")
82
+ if (file === "NOTICE") {
83
+ notice = text
84
+ continue
85
+ }
86
+ const parsed = parseHeader(text)
87
+ if (!parsed) throw new Error(`blocks/${entry.name}/${file} has no block header`)
88
+ if (parsed.name !== entry.name) throw new Error(`blocks/${entry.name}/${file} names block ${parsed.name}`)
89
+ files.push({ file, text, header: parsed.header, body: parsed.body, sha256: bodySha256(parsed.body), headerSha256: parsed.sha256, version: parsed.version })
90
+ }
91
+ if (!files.length) continue
92
+ const versions = new Set(files.map((file) => file.version))
93
+ if (versions.size !== 1) throw new Error(`blocks/${entry.name} carries ${versions.size} versions`)
94
+ blocks.push({ name: entry.name, version: files[0].version, dir: blockDir, files, notice })
95
+ }
96
+ return blocks
97
+ }
98
+
99
+ /** One shipped block by name, or null. */
100
+ export function shippedBlock(name, dir = BLOCKS_DIR) {
101
+ return shippedBlocks(dir).find((block) => block.name === name) || null
102
+ }
103
+
104
+ /**
105
+ * What a file found in a plugin tree is, judged by its header and body
106
+ * alone: `unmodified` when a block header names a shipped block and the
107
+ * body's sha256 is that block's for that file; `modified` when it carries
108
+ * a block header but the body is not one this omakit ships; null when it
109
+ * has no block header at all.
110
+ *
111
+ * @param {string} file the base name, e.g. Run.qml
112
+ * @param {string} text
113
+ * @param {ReturnType<typeof shippedBlocks>} [blocks]
114
+ * An older shipped body (blocks/history.json) is unmodified at its own
115
+ * version, which the row names beside the shipped one.
116
+ *
117
+ * @returns {{ name: string, version: string, state: "unmodified"|"modified", shippedVersion: string|null } | null}
118
+ */
119
+ export function recogniseBlockFile(file, text, blocks = shippedBlocks(), history = shippedHistory()) {
120
+ const parsed = parseHeader(text)
121
+ if (!parsed) return null
122
+ const shipped = blocks.find((block) => block.name === parsed.name)
123
+ const sha = bodySha256(parsed.body)
124
+ const known = shipped?.files.find((entry) => entry.file === file)
125
+ const older = !known || known.sha256 !== sha ? history.find((row) => row.block === parsed.name && row.file === file && row.sha256 === sha) : null
126
+ const state = known && known.sha256 === sha ? "unmodified" : older ? "unmodified" : "modified"
127
+ return { name: parsed.name, version: older ? older.version : parsed.version, state, shippedVersion: shipped ? shipped.version : null }
128
+ }
129
+
130
+ /** The header line that carries the body hash, rewritten to the given digest. */
131
+ export function withBodySha256(text, sha256) {
132
+ return String(text).replace(/^((?:\/\/|#) ?Body sha256: )[0-9a-f]{64}$/m, `$1${sha256}`)
133
+ }
134
+
135
+ /** The header's source line with the commit stamped in. */
136
+ export function withSourceCommit(text, commit) {
137
+ return String(text).replace(/^((?:\/\/|#) ?Source: omakit [^,\n]+, commit )\S+$/m, `$1${commit}`)
138
+ }
139
+
140
+ /**
141
+ * The NOTICE for a set of blocks in one `omakit/` directory: per file the
142
+ * block, version, licence, copyright, source path and commit, and the
143
+ * body sha256, so the review's vendored-code expectation (record the
144
+ * upstream, keep the licence, disclose modifications) is met by one file.
145
+ *
146
+ * @param {Array<{ name: string, version: string, files: Array<{ file: string, sha256: string }> }>} blocks
147
+ * @param {string} commit the omakit commit the files came from, or "unstamped"
148
+ */
149
+ export function renderNotice(blocks, commit) {
150
+ const lines = [
151
+ "omakit blocks in this directory",
152
+ "",
153
+ "Each file below was copied from omakit (https://github.com/mtolhuys/omakit),",
154
+ "MIT licence, Copyright (c) 2026 Maarten Tolhuijs, by `omakit add`. The body",
155
+ "sha256 is over everything after the file's header line",
156
+ `\`${HEADER_END}\`; \`omakit inspect\` reports a file whose body differs as`,
157
+ "modified, and `omakit add --update` refuses to overwrite one. Modifications",
158
+ "are to be listed here by the plugin's author.",
159
+ "",
160
+ ]
161
+ for (const block of blocks) {
162
+ lines.push(`block ${block.name} ${block.version}, from omakit commit ${commit}`)
163
+ for (const file of block.files) lines.push(` ${file.file} sha256 ${file.sha256}`)
164
+ lines.push("")
165
+ }
166
+ return `${lines.join("\n")}\n`.replace(/\n\n$/, "\n")
167
+ }
168
+
169
+ /**
170
+ * Every body sha256 omakit ever shipped for a block file, from
171
+ * blocks/history.json (appended by tools/blocks/stamp.mjs at every block
172
+ * change, never pruned) plus the current files: what `omakit add --update`
173
+ * may replace, and what `inspect` names as an older unmodified copy.
174
+ *
175
+ * @returns {Array<{ block: string, version: string, file: string, sha256: string }>}
176
+ */
177
+ export function shippedHistory(dir = BLOCKS_DIR) {
178
+ let history = []
179
+ try {
180
+ history = JSON.parse(readFileSync(join(dir, "history.json"), "utf8"))
181
+ } catch {
182
+ history = []
183
+ }
184
+ const rows = [...history]
185
+ for (const block of shippedBlocks(dir)) {
186
+ for (const entry of block.files) {
187
+ if (!rows.some((row) => row.block === block.name && row.file === entry.file && row.sha256 === entry.sha256)) rows.push({ block: block.name, version: block.version, file: entry.file, sha256: entry.sha256 })
188
+ }
189
+ }
190
+ return rows
191
+ }
@@ -0,0 +1,61 @@
1
+ // Maintainer's tool: rewrite the `Body sha256` line of every shipped block
2
+ // file to its body's digest and regenerate blocks/<name>/NOTICE, so the
3
+ // header a plugin author reads and the hash inspect recognises are one
4
+ // number. Run after editing a block:
5
+ //
6
+ // node tools/blocks/stamp.mjs # writes; prints what changed
7
+ // node tools/blocks/stamp.mjs --check # exits 1 when a header is stale
8
+ //
9
+ // Writes only under this checkout's blocks/ directory, never into a
10
+ // plugin tree; tests/unit/self-containment.test.mjs holds it to that.
11
+
12
+ import { readFileSync, writeFileSync } from "node:fs"
13
+ import { join } from "node:path"
14
+ import { BLOCKS_DIR, renderNotice, shippedBlocks, shippedHistory, withBodySha256 } from "./registry.mjs"
15
+
16
+ const check = process.argv.includes("--check")
17
+ let stale = 0
18
+ for (const block of shippedBlocks()) {
19
+ for (const entry of block.files) {
20
+ const stamped = withBodySha256(entry.text, entry.sha256)
21
+ if (stamped !== entry.text) {
22
+ stale += 1
23
+ process.stdout.write(`${check ? "stale" : "stamped"} blocks/${block.name}/${entry.file} ${entry.sha256}\n`)
24
+ const blockFile = join(block.dir, entry.file)
25
+ if (!check) writeFileSync(blockFile, stamped)
26
+ }
27
+ }
28
+ const notice = renderNotice([block], "unstamped")
29
+ let current = null
30
+ try {
31
+ current = readFileSync(join(block.dir, "NOTICE"), "utf8")
32
+ } catch {
33
+ current = null
34
+ }
35
+ if (current !== notice) {
36
+ stale += 1
37
+ process.stdout.write(`${check ? "stale" : "written"} blocks/${block.name}/NOTICE\n`)
38
+ const blockFile = join(block.dir, "NOTICE")
39
+ if (!check) writeFileSync(blockFile, notice)
40
+ }
41
+ }
42
+ // history.json: every (block, version, file, sha256) ever shipped, so
43
+ // `add --update` can tell an older copy from a modified one. Append-only.
44
+ {
45
+ const rows = shippedHistory()
46
+ const text = `${JSON.stringify(rows, null, 1)}\n`
47
+ let current = null
48
+ try {
49
+ current = readFileSync(join(BLOCKS_DIR, "history.json"), "utf8")
50
+ } catch {
51
+ current = null
52
+ }
53
+ if (current !== text) {
54
+ stale += 1
55
+ process.stdout.write(`${check ? "stale" : "written"} blocks/history.json (${rows.length} shipped file versions)\n`)
56
+ const blockFile = join(BLOCKS_DIR, "history.json")
57
+ if (!check) writeFileSync(blockFile, text)
58
+ }
59
+ }
60
+ if (!stale) process.stdout.write("every block header carries its body's sha256 and every NOTICE is current\n")
61
+ process.exitCode = check && stale ? 1 : 0
@@ -11,8 +11,9 @@ import { resolve } from "node:path"
11
11
  import { pathToFileURL } from "node:url"
12
12
 
13
13
  const ARGV_FORMS = new Set(["array", "string", "computed"])
14
- const DEADLINE_VIA = new Set(["timer-kill", "timeout-argv", "destruction", null])
15
- const COLLECTORS = new Set(["StdioCollector", "SplitParser", "none", "unknown"])
14
+ const DEADLINE_VIA = new Set(["timer-kill", "timeout-argv", "destruction", "block-run", null])
15
+ const COLLECTORS = new Set(["StdioCollector", "SplitParser", "Run", "none", "unknown"])
16
+ const BLOCK_STATES = new Set(["unmodified", "modified"])
16
17
  const CONTROLLED = new Set(["observed", "not-observed", "unknown"])
17
18
  const DECLARED_IN = new Set(["qml", "shell"])
18
19
  const FILE_KINDS = ["qml", "js", "shell", "python", "other"]
@@ -44,14 +45,18 @@ function processRow(row, at, problems) {
44
45
  if (row.argvForm === "array" && Array.isArray(row.argv)) {
45
46
  for (const entry of row.expressions || []) if (row.argv[entry.index] !== entry.text) problems.push(`${at}.expressions[${entry.index}] does not name the argv element it stands for`)
46
47
  }
47
- for (const key of ["running", "detached", "shellWrapper"]) if (!isBool(row[key])) problems.push(`${at}.${key} is not a boolean`)
48
+ for (const key of ["running", "detached", "shellWrapper", "closedEnvironment"]) if (!isBool(row[key])) problems.push(`${at}.${key} is not a boolean`)
49
+ if (row.closedEnvironment && row.declaredIn !== "shell") problems.push(`${at}.closedEnvironment is set on a site that is not a shell line`)
48
50
  const deadline = row.deadline || {}
49
51
  if (!isBool(deadline.observed)) problems.push(`${at}.deadline.observed is not a boolean`)
50
- if (!DEADLINE_VIA.has(deadline.via)) problems.push(`${at}.deadline.via is not timer-kill, timeout-argv, destruction or null`)
52
+ if (!DEADLINE_VIA.has(deadline.via)) problems.push(`${at}.deadline.via is not timer-kill, timeout-argv, destruction, block-run or null`)
53
+ if ((deadline.via === "block-run") !== (row.block === "run")) problems.push(`${at}.deadline.via block-run and block: "run" go together`)
54
+ if (row.block === "run" && !nullOr(isString)(row.helper)) problems.push(`${at}.helper is neither a tree path nor null`)
55
+ if (row.block !== "run" && row.helper !== undefined) problems.push(`${at}.helper is only for a Run site`)
51
56
  if (deadline.observed !== (deadline.via !== null)) problems.push(`${at}.deadline.observed disagrees with deadline.via`)
52
57
  if (!nullOr(isInt)(deadline.ms)) problems.push(`${at}.deadline.ms is neither an integer nor null`)
53
58
  const output = row.output || {}
54
- if (!COLLECTORS.has(output.collector)) problems.push(`${at}.output.collector is not StdioCollector, SplitParser, none or unknown`)
59
+ if (!COLLECTORS.has(output.collector)) problems.push(`${at}.output.collector is not StdioCollector, SplitParser, Run, none or unknown`)
55
60
  if (!isBool(output.capObserved)) problems.push(`${at}.output.capObserved is not a boolean`)
56
61
  if (!nullOr(isString)(output.via)) problems.push(`${at}.output.via is neither a string nor null`)
57
62
  if (output.capObserved !== (output.via !== null)) problems.push(`${at}.output.capObserved disagrees with output.via`)
@@ -83,6 +88,7 @@ function write(row, at, problems) {
83
88
  if (row.canonicalPath === null && row.controlledDirectory !== "unknown") problems.push(`${at}.controlledDirectory is decided for a path that could not be read`)
84
89
  if (!isBool(row.temp)) problems.push(`${at}.temp is not a boolean`)
85
90
  if (!nullOr(isString)(row.mode)) problems.push(`${at}.mode is neither a string nor null`)
91
+ if ((row.via === "block-store") !== (row.block === "store")) problems.push(`${at}.via block-store and block: "store" go together`)
86
92
  }
87
93
 
88
94
  function fn(row, at, problems) {
@@ -203,6 +209,31 @@ export function validateInspectDocument(document, known = {}) {
203
209
  }
204
210
  }
205
211
  }
212
+ if (!Array.isArray(document.blocks)) problems.push("blocks is not a list")
213
+ else for (const [index, row] of document.blocks.entries()) {
214
+ const at = `blocks[${index}]`
215
+ if (!isString(row.name) || !/^[a-z][a-z0-9-]*$/.test(row.name)) problems.push(`${at}.name is not a block name`)
216
+ if (!isString(row.version)) problems.push(`${at}.version is not a string`)
217
+ if (!nullOr(isString)(row.shippedVersion)) problems.push(`${at}.shippedVersion is neither a string nor null`)
218
+ if (!BLOCK_STATES.has(row.state)) problems.push(`${at}.state is not unmodified or modified`)
219
+ if (!isBool(row.complete)) problems.push(`${at}.complete is not a boolean`)
220
+ if (!Array.isArray(row.files) || !row.files.length) problems.push(`${at}.files is not a non-empty list`)
221
+ else {
222
+ for (const [fileIndex, file] of row.files.entries()) {
223
+ if (!isString(file.path)) problems.push(`${at}.files[${fileIndex}].path is not a string`)
224
+ if (!BLOCK_STATES.has(file.state)) problems.push(`${at}.files[${fileIndex}].state is not unmodified or modified`)
225
+ if (!isString(file.version)) problems.push(`${at}.files[${fileIndex}].version is not a string`)
226
+ }
227
+ if ((row.state === "unmodified") !== row.files.every((file) => file.state === "unmodified")) problems.push(`${at}.state disagrees with its files`)
228
+ // An unmodified block's files were not read: no row of any kind at them.
229
+ if (row.state === "unmodified" && observed) {
230
+ const paths = new Set(row.files.map((file) => file.path))
231
+ for (const key of ["processes", "hosts", "writes", "timers", "functions"]) {
232
+ for (const fact of Array.isArray(observed[key]) ? observed[key] : []) if (paths.has(fact.file)) problems.push(`observed.${key} has a row at ${fact.file}:${fact.line}, a file of unmodified block ${row.name}`)
233
+ }
234
+ }
235
+ }
236
+ }
206
237
  if (!Array.isArray(document.patterns)) problems.push("patterns is not a list")
207
238
  else for (const [index, row] of document.patterns.entries()) {
208
239
  const at = `patterns[${index}]`
@@ -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,6 +199,10 @@ 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.