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,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) {
@@ -169,7 +175,7 @@ export function validateInspectDocument(document, known = {}) {
169
175
  for (const key of ["lines", "branches", "depth"]) if (!isInt(size.thresholds?.[key]) || size.thresholds[key] < 1) problems.push(`size.thresholds.${key} is not a count`)
170
176
  const shares = size.sample?.heavyShares
171
177
  if (!size.sample || !isInt(size.sample.trees) || !isInt(size.sample.functions)) problems.push("size.sample is not { trees, functions, heavyShares }")
172
- else if (!Array.isArray(shares) || shares.length !== size.sample.trees || !shares.every((share) => typeof share === "number" && share >= 0 && share <= 1)) problems.push("size.sample.heavyShares is not one share from 0 to 1 per listed tree")
178
+ else if (!Array.isArray(shares) || !shares.length || shares.length > size.sample.trees || !shares.every((share) => typeof share === "number" && share >= 0 && share <= 1)) problems.push("size.sample.heavyShares is not one share from 0 to 1 per listed tree with a function")
173
179
  const scorable = Array.isArray(observed.functions) && observed.functions.length > 0
174
180
  if (typeof size.heavyShare !== "number" || size.heavyShare < 0 || size.heavyShare > 1) problems.push("size.heavyShare is not a share from 0 to 1")
175
181
  else if (Array.isArray(observed.functions) && size.thresholds) {
@@ -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}]`
@@ -9,9 +9,32 @@
9
9
  import { blankComments, closingBracket, lineOf } from "./text.mjs"
10
10
 
11
11
  const JS_BRANCH = /\b(?:if|else if|for|while|do|switch|case|catch)\b|&&|\|\||\?[^.:]/g
12
- const SHELL_OPEN = /^\s*(?:if|for|while|until|case|select)\b/
13
- const SHELL_CLOSE = /^\s*(?:fi|done|esac)\b/
14
- const SHELL_BRANCH = /\b(?:if|elif|for|while|until|case)\b|\|\||&&|^\s*[^)]*\)\s*(?!\s*$)/
12
+ // A block opens with `if`, `for`, `while`, `until`, `case` or `select` and
13
+ // closes with `fi`, `done` or `esac`, each counted only where a statement
14
+ // can start (the line's start, after `;`, `&`, `|`, `(`, `then`, `do` or
15
+ // `else`), over the line with its quoted text removed, so `if x; then y;
16
+ // fi` on one line nets zero and is not a level, `x && if y; then z; fi`
17
+ // nets zero too, and neither `echo "done"` nor `echo done` closes anything.
18
+ const SHELL_OPEN = /(?:^|[;&|(]|\b(?:then|do|else))\s*(?:if|for|while|until|case|select)\b/g
19
+ const SHELL_CLOSE = /(?:^|[;&|(]|\b(?:then|do|else))\s*(?:fi|done|esac)\b/g
20
+ // A case arm, `pattern) command` or `(pattern) command`, is a branch: a `)`
21
+ // with text after it on a line with no `(` before it but an opening one,
22
+ // so a `$(...)` or `(( ))` in a test is not one.
23
+ const SHELL_BRANCH = /\b(?:if|elif|for|while|until|case)\b|\|\||&&|^\s*\(?[^()]*\)\s*(?!\s*$)/
24
+ // A guard, not a branch: `||` or `&&` followed by one flow word (`return`,
25
+ // `exit`, `continue`, `break`, `true`, `false`, `:`) with an optional
26
+ // status (a number, `$?` or a variable), then nothing but a `;` or the `;;`
27
+ // that ends a case arm, as in `[[ -f $x ]] || return 1`. One guard per
28
+ // line, the one at its end: `x && return 0 || return 1` is a choice, and
29
+ // its `&&` counts. JavaScript has no such idiom, so shell alone is
30
+ // exempted.
31
+ // Matched against the trimmed end of the code, and anchored there, so a long run of spaces costs nothing.
32
+ const SHELL_GUARD = /(?:\|\||&&)\s*(?:return|exit|continue|break|true|false|:)(?:\s+(?:\$\?|\$\{?\w+\}?|\d+))?\s*;{0,2}$/
33
+ // `<<WORD`, `<<-WORD`, `<<'WORD'`, `<<"WORD"` or `<<\WORD` outside quotes and
34
+ // outside `(( ))`; `<<<` is a here-string and `<<` in arithmetic a shift.
35
+ const HEREDOC = /^<<(-?)\s*(?:(['"])([A-Za-z_][\w.-]*)\2|\\?([A-Za-z_][\w.-]*))/
36
+ // The line that closes a shell function: `}` alone, or `}` with a comment or a redirection after it.
37
+ const SHELL_END = /^\}\s*(?:#.*|[<>&|].*)?$/
15
38
  const PY_BRANCH = /^\s*(?:if|elif|for|while|except|with)\b|\band\b|\bor\b/
16
39
 
17
40
  /**
@@ -91,6 +114,105 @@ function jsFunctions(file) {
91
114
  return rows
92
115
  }
93
116
 
117
+ /**
118
+ * One shell line read left to right with a stack of contexts: a
119
+ * single-quoted string, a double-quoted string, an ANSI-C `$'...'` string,
120
+ * and, nested in a double-quoted string, `$(...)`, `${...}` and a
121
+ * backtick substitution, which is what lets `"$(printf "it's")"` and
122
+ * `"${x:-"it's"}"` read their inner quotes as their own. It returns the
123
+ * code before a `#` that starts a comment, the heredoc the line opens, and
124
+ * the contexts left open at its end. Quoted text is data and left out of
125
+ * the code, whether the quote closes on the line or spans lines (the
126
+ * heredoc delimiter is read here, before the quotes go, so `<<'PY'` and
127
+ * `<<PY` read alike), while what a substitution holds is shell and kept. A `'` inside double quotes
128
+ * ("Okomart's") and a `#` inside quotes (`*'#'*`) are text; a backslash
129
+ * escapes in code, inside double quotes and inside `$'...'`, and inside
130
+ * plain single quotes nothing does. `<<` in shell, at the top or inside a
131
+ * substitution, and outside `(( ))`, is a heredoc.
132
+ * @param {string} line
133
+ * @param {Array<{ kind: string, depth: number }>} open the contexts open from the line above, innermost last
134
+ * @returns {{ code: string, open: Array<{ kind: string, depth: number }>, heredoc: { word: string, strip: boolean } | null }}
135
+ */
136
+ function scanShellLine(line, open) {
137
+ const stack = open.map((entry) => ({ ...entry }))
138
+ let code = ""
139
+ let heredoc = null
140
+ // Depth of `((` arithmetic on this line, inside which `<<` is a shift.
141
+ let arith = 0
142
+ const top = () => stack[stack.length - 1] || null
143
+ const isCode = (kind) => kind === "code" || kind === "$(" || kind === "${" || kind === "`"
144
+ // Text inside a string is data; text in shell, nested or not, is kept.
145
+ const keep = () => {
146
+ const inner = top()
147
+ return !inner || isCode(inner.kind)
148
+ }
149
+ for (let i = 0; i < line.length; i += 1) {
150
+ const ch = line[i]
151
+ const context = top()
152
+ const kind = context ? context.kind : "code"
153
+ if (kind === "'") {
154
+ if (ch === "'") stack.pop()
155
+ else if (keep()) code += ch
156
+ continue
157
+ }
158
+ if (ch === "\\") {
159
+ i += 1
160
+ if (keep()) code += ch + (line[i] ?? "")
161
+ continue
162
+ }
163
+ if (kind === '"' || kind === "$'") {
164
+ if (ch === kind[kind.length - 1]) stack.pop()
165
+ else if (kind === '"' && ch === "$" && (line[i + 1] === "(" || line[i + 1] === "{")) {
166
+ stack.push({ kind: `$${line[i + 1]}`, depth: 0 })
167
+ code += `$${line[i + 1]}`
168
+ i += 1
169
+ // `$((` under a quote is arithmetic: its `<<` is a shift.
170
+ if (line[i] === "(" && line[i + 1] === "(") arith += 1
171
+ } else if (kind === '"' && ch === "`") {
172
+ stack.push({ kind: "`", depth: 0 })
173
+ code += ch
174
+ } else if (keep()) code += ch
175
+ continue
176
+ }
177
+ // Shell code: at the top, or inside a substitution under a double quote.
178
+ if (kind === "$(" || kind === "${") {
179
+ const [opener, closer] = kind === "$(" ? ["(", ")"] : ["{", "}"]
180
+ if (ch === opener) context.depth += 1
181
+ else if (ch === closer) {
182
+ if (context.depth === 0) {
183
+ stack.pop()
184
+ code += ch
185
+ continue
186
+ }
187
+ context.depth -= 1
188
+ }
189
+ } else if (kind === "`" && ch === "`") {
190
+ stack.pop()
191
+ code += ch
192
+ continue
193
+ }
194
+ if (ch === "#" && (i === 0 || /[\s;()&|]/.test(line[i - 1]))) break
195
+ if (ch === "'" || ch === '"') {
196
+ stack.push({ kind: ch === "'" && line[i - 1] === "$" ? "$'" : ch, depth: 0 })
197
+ continue
198
+ }
199
+ if (ch === "(" && line[i + 1] === "(") arith += 1
200
+ else if (ch === ")" && line[i + 1] === ")" && arith) arith -= 1
201
+ if (ch === "<" && line[i + 1] === "<" && line[i - 1] !== "<" && line[i + 2] !== "<" && !heredoc && !arith) {
202
+ const found = line.slice(i).match(HEREDOC)
203
+ if (found) heredoc = { word: found[3] || found[4], strip: found[1] === "-" }
204
+ }
205
+ code += ch
206
+ }
207
+ return { code, open: stack.map(({ kind, depth }) => ({ kind, depth })), heredoc }
208
+ }
209
+
210
+ /** The code with any guard tail removed, so what is left is what SHELL_BRANCH reads. */
211
+ function withoutGuards(code) {
212
+ // The one guard at the end: `a || b && return` is one guard over a real `||`.
213
+ return code.trimEnd().replace(SHELL_GUARD, "").trimEnd()
214
+ }
215
+
94
216
  function shellFunctions(file) {
95
217
  const lines = file.text.split("\n")
96
218
  const rows = []
@@ -104,18 +226,37 @@ function shellFunctions(file) {
104
226
  let depth = 0
105
227
  let deepest = 0
106
228
  let branches = 0
229
+ // Data inside the function is not shell: the body of a heredoc up to its
230
+ // delimiter alone on a line (leading tabs allowed after `<<-`), and
231
+ // quoted text, on one line or spanning lines (an awk or python program
232
+ // in single quotes, a remote command in double quotes). Those count
233
+ // toward the length and toward nothing else; the shell around them,
234
+ // and inside a substitution nested in them, is read. Two heredocs on
235
+ // one line: the first is tracked, the second's body is read as shell.
236
+ let heredoc = null
237
+ let open = []
107
238
  for (let at = index + 1; at < lines.length; at += 1) {
108
239
  const line = lines[at]
109
- if (line.trim() === "}" && line.startsWith(indent) && line.match(/^\s*/)[0].length === indent.length) {
240
+ if (heredoc) {
241
+ if ((heredoc.strip ? line.replace(/^\t+/, "") : line) === heredoc.word) heredoc = null
110
242
  end = at
111
- break
243
+ continue
112
244
  }
113
- if (SHELL_OPEN.test(line)) {
114
- depth += 1
115
- if (depth > deepest) deepest = depth
245
+ if (!open.length && SHELL_END.test(line.trim()) && line.startsWith(indent) && line.match(/^\s*/)[0].length === indent.length) {
246
+ end = at
247
+ break
116
248
  }
117
- if (SHELL_CLOSE.test(line)) depth -= 1
118
- if (SHELL_BRANCH.test(line.split("#")[0])) branches += 1
249
+ const scanned = scanShellLine(line, open)
250
+ const code = withoutGuards(scanned.code)
251
+ open = scanned.open
252
+ heredoc = scanned.heredoc
253
+ // A line that opens inside a string is read only after the string closes: no `if` at its start, only what the code holds.
254
+ // The line's net over its code: `if x; then y; fi` on one line is no level.
255
+ const net = (code.match(SHELL_OPEN) || []).length - (code.match(SHELL_CLOSE) || []).length
256
+ // Never below the body: a close the scanner misread cannot hide every later level.
257
+ depth = Math.max(0, depth + net)
258
+ if (depth > deepest) deepest = depth
259
+ if (SHELL_BRANCH.test(code)) branches += 1
119
260
  end = at
120
261
  }
121
262
  rows.push({ file: file.path, line: index + 1, name, kind: "function", lines: end - index + 1, depth: deepest, branches })
@@ -124,11 +265,63 @@ function shellFunctions(file) {
124
265
  return rows
125
266
  }
126
267
 
268
+ /**
269
+ * Opening brackets minus closing ones on a line, outside string literals
270
+ * and comments, carrying the state of a triple-quoted string across lines
271
+ * so a bracket inside a docstring or an SQL text counts nothing; and the
272
+ * code of the line with its strings and comment removed, so `and`, `or`
273
+ * and `if` in prose are not branches.
274
+ * @param {string} line
275
+ * @param {string|null} triple the triple quote open from the line above, or null
276
+ * @returns {{ balance: number, triple: string|null, continued: boolean, code: string }} `continued` when the line ends in a backslash outside a string
277
+ */
278
+ function bracketBalance(line, triple) {
279
+ let balance = 0
280
+ let quote = triple
281
+ let code = ""
282
+ let i = 0
283
+ for (; i < line.length; i += 1) {
284
+ const ch = line[i]
285
+ if (quote) {
286
+ if (ch === "\\") i += 1
287
+ else if (quote.length === 3 ? line.startsWith(quote, i) : ch === quote) {
288
+ i += quote.length - 1
289
+ quote = null
290
+ }
291
+ continue
292
+ }
293
+ if (ch === "#") break
294
+ if (ch === '"' || ch === "'") {
295
+ quote = line.startsWith(ch.repeat(3), i) ? ch.repeat(3) : ch
296
+ i += quote.length - 1
297
+ continue
298
+ }
299
+ if (ch === "(" || ch === "[" || ch === "{") balance += 1
300
+ else if (ch === ")" || ch === "]" || ch === "}") balance -= 1
301
+ code += ch
302
+ }
303
+ // A single quote never spans a line; a triple one does.
304
+ const open = quote && quote.length === 3 ? quote : null
305
+ return { balance, triple: open, continued: !open && /\\$/.test(code.trimEnd()), code }
306
+ }
307
+
308
+ const PY_DEF = /^(\s*)(?:async\s+)?def\s+([A-Za-z_]\w*)\s*\(/
309
+ const PY_CLOSER = /^\s*[)\]}]/
310
+
127
311
  function pythonFunctions(file) {
128
312
  const lines = file.text.split("\n")
129
313
  const rows = []
314
+ // Whether each line starts inside a triple-quoted string, over the whole
315
+ // file, so a `def` quoted in a docstring's example is not a function.
316
+ const quoted = new Array(lines.length)
317
+ let moduleTriple = null
318
+ for (let at = 0; at < lines.length; at += 1) {
319
+ quoted[at] = moduleTriple !== null
320
+ moduleTriple = bracketBalance(lines[at], moduleTriple).triple
321
+ }
130
322
  for (let index = 0; index < lines.length; index += 1) {
131
- const head = lines[index].match(/^(\s*)(?:async\s+)?def\s+([A-Za-z_]\w*)\s*\(/)
323
+ if (quoted[index]) continue
324
+ const head = lines[index].match(PY_DEF)
132
325
  if (!head) continue
133
326
  const base = head[1].length
134
327
  let end = index
@@ -140,18 +333,49 @@ function pythonFunctions(file) {
140
333
  // PEP 8, 2 in some trees); 4 is assumed when there is no body line.
141
334
  let unit = 4
142
335
  let first = true
336
+ // A line that starts while a bracket is open, inside a triple-quoted
337
+ // string, or after a line ending in a backslash is a continuation of
338
+ // the statement above it (the arguments of a multi-line call, a
339
+ // docstring, a split condition): it counts toward the length and the
340
+ // branches, never toward depth, and never sets the unit. The def's own
341
+ // parameter list, when it spans lines, is a continuation of the def.
342
+ // The balance is over `(`, `[` and `{` minus their closers, outside
343
+ // string literals, the way braceDepth skips quotes. A miscount cannot
344
+ // run past the function: a bracket or backslash continuation ends at
345
+ // the first line at the def's indent or shallower that is not a
346
+ // closing bracket, whatever the balance says; a triple-quoted string
347
+ // runs to its close, since an SQL or help text inside it may sit at
348
+ // column 0.
349
+ let state = bracketBalance(lines[index], null)
350
+ let balance = Math.max(0, state.balance)
351
+ let triple = state.triple
352
+ let continued = state.continued
143
353
  for (let at = index + 1; at < lines.length; at += 1) {
144
354
  const line = lines[at]
145
355
  if (!line.trim()) continue
146
356
  const indent = line.match(/^\s*/)[0].length
357
+ const continuation = balance > 0 || triple !== null || continued
358
+ if (continuation && triple === null && indent <= base && !PY_CLOSER.test(line)) break
359
+ state = bracketBalance(line, triple)
360
+ if (continuation) {
361
+ balance = Math.max(0, balance + state.balance)
362
+ triple = state.triple
363
+ continued = state.continued
364
+ if (PY_BRANCH.test(state.code)) branches += 1
365
+ end = at
366
+ continue
367
+ }
147
368
  if (indent <= base) break
369
+ balance = Math.max(0, state.balance)
370
+ triple = state.triple
371
+ continued = state.continued
148
372
  if (first) {
149
373
  unit = indent - base
150
374
  first = false
151
375
  }
152
376
  const level = Math.max(0, Math.floor((indent - base) / unit) - 1)
153
377
  if (level > deepest) deepest = level
154
- if (PY_BRANCH.test(line)) branches += 1
378
+ if (PY_BRANCH.test(state.code)) branches += 1
155
379
  end = at
156
380
  }
157
381
  rows.push({ file: file.path, line: index + 1, name: head[2], kind: "function", lines: end - index + 1, depth: deepest, branches })