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.
- package/README.md +38 -45
- package/blocks/history.json +68 -0
- package/blocks/run/NOTICE +12 -0
- package/blocks/run/Run.qml +242 -0
- package/blocks/run/run-supervisor.py +522 -0
- package/blocks/store/NOTICE +12 -0
- package/blocks/store/Store.qml +157 -0
- package/blocks/store/store-helper.py +431 -0
- package/package.json +12 -5
- package/skills/omarchy-plugin-audit/SKILL.md +11 -5
- package/skills/omarchy-plugin-build/SKILL.md +164 -0
- package/skills/omarchy-plugin-check/SKILL.md +6 -3
- package/skills/omarchy-plugin-submit/SKILL.md +4 -1
- package/skills/omarchy-plugin-validation-watch/SKILL.md +5 -2
- package/skills/omarchy-plugin-weigh/SKILL.md +13 -2
- package/tests/fixtures/weigh/clean/Widget.qml +19 -0
- package/tests/fixtures/weigh/clean/manifest.json +9 -0
- package/tests/fixtures/weigh/clean/tests/harness.qml +7 -0
- package/tests/fixtures/weigh/idle-panel/Panel.qml +65 -0
- package/tests/fixtures/weigh/idle-panel/manifest.json +9 -0
- package/tests/fixtures/weigh/poller/Service.qml +50 -0
- package/tests/fixtures/weigh/poller/manifest.json +9 -0
- package/tests/fixtures/weigh/timer-180ms/Widget.qml +25 -0
- package/tests/fixtures/weigh/timer-180ms/manifest.json +9 -0
- package/tests/lab/run/harness/scenarios/controls.sh +6 -0
- package/tests/lab/run/harness/scenarios/envprobe.sh +10 -0
- package/tests/lab/run/harness/scenarios/forge.sh +11 -0
- package/tests/lab/run/harness/scenarios/holder.sh +5 -0
- package/tests/lab/run/harness/scenarios/orphan.sh +6 -0
- package/tests/lab/run/harness/scenarios/stall.sh +5 -0
- package/tests/lab/run/harness/scenarios/stubborn.sh +5 -0
- package/tests/lab/run/harness/scenarios/tree.sh +7 -0
- package/tests/lab/run/harness/shell.qml +84 -0
- package/tests/lab/run/report.py +217 -0
- package/tests/lab/run/suite.sh +106 -0
- package/tests/lab/store/harness/shell.qml +73 -0
- package/tests/lab/store/report.py +133 -0
- package/tests/lab/store/suite.sh +109 -0
- package/tests/parity/corpus.mjs +8 -3
- package/tests/parity/run.mjs +4 -4
- package/tools/audit/audit.mjs +17 -6
- package/tools/audit/git.mjs +3 -3
- package/tools/audit/report.mjs +31 -5
- package/tools/blocks/add.mjs +138 -0
- package/tools/blocks/commit.json +5 -0
- package/tools/blocks/record-commit.mjs +77 -0
- package/tools/blocks/registry.mjs +191 -0
- package/tools/blocks/stamp.mjs +61 -0
- package/tools/inspect/contract.mjs +36 -5
- package/tools/inspect/helpers.mjs +217 -0
- package/tools/inspect/inspect.mjs +68 -3
- package/tools/inspect/patterns.mjs +18 -3
- package/tools/inspect/processes.mjs +38 -5
- package/tools/inspect/report.mjs +18 -2
- package/tools/inspect/writes.mjs +22 -4
- package/tools/lab/guest.mjs +155 -0
- package/tools/lab/harness.sh +119 -0
- package/tools/lab/host.mjs +177 -0
- package/tools/lab/inspect.mjs +240 -0
- package/tools/lab/omarchy.gpg +13 -0
- package/tools/lab/patches/omarchy-iso-test.patch +351 -0
- package/tools/lab/paths.mjs +173 -0
- package/tools/lab/pin.json +42 -0
- package/tools/lab/pin.mjs +64 -0
- package/tools/lab/prune.mjs +68 -0
- package/tools/lab/qemu.mjs +153 -0
- package/tools/lab/qmp-cli.mjs +21 -0
- package/tools/lab/report.mjs +183 -0
- package/tools/lab/run.mjs +344 -0
- package/tools/lab/setup.mjs +430 -0
- package/tools/lab/suites/run.sh +35 -0
- package/tools/lab/suites/store.sh +41 -0
- package/tools/lab/suites/weigh.sh +196 -0
- package/tools/lab/suites.mjs +142 -0
- package/tools/lab/verify.mjs +134 -0
- package/tools/marketplace/README.md +38 -1
- package/tools/marketplace/banner.mjs +23 -2
- package/tools/marketplace/cli.mjs +449 -146
- package/tools/marketplace/completion-check.mjs +27 -1
- package/tools/marketplace/completion.mjs +32 -4
- package/tools/marketplace/doctor.mjs +47 -9
- package/tools/marketplace/github.mjs +52 -6
- package/tools/marketplace/local-transport.mjs +1 -1
- package/tools/marketplace/options.mjs +16 -5
- package/tools/marketplace/outcome.mjs +244 -0
- package/tools/marketplace/pin.mjs +178 -33
- package/tools/marketplace/setup.mjs +16 -15
- package/tools/marketplace/tree.mjs +1 -1
- package/tools/marketplace/upgrade.mjs +5 -5
- package/tools/marketplace/usage.mjs +116 -72
- package/tools/subject/resolve.mjs +19 -6
- package/tools/weigh/audit.mjs +47 -16
- package/tools/weigh/config.mjs +105 -24
- 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.
|