omakit 0.1.9 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,69 @@
1
+ // The Omarchy commands `omakit weigh` runs, as a frozen table, and the one call
2
+ // site that runs them.
3
+ //
4
+ // This is the one part of omakit that changes the user's own machine: it
5
+ // restarts their shell. So what it can run is a list, not a search. Every
6
+ // entry names its binary and its arguments; `run()` is the only spawn under
7
+ // tools/weigh/ (tests/unit/read-only.test.mjs holds it to that), and the only
8
+ // entry that takes an argument at run time is the shell pid lookup, which
9
+ // needs the shell's configuration directory. No `quickshell kill`, no
10
+ // `hyprctl`, no `systemctl` beyond reading the session's environment: the
11
+ // restart is `omarchy-restart-shell`, the same command a person runs, with
12
+ // its own lock check and its own readiness poll.
13
+
14
+ import { spawnSync } from "node:child_process"
15
+
16
+ export const COMMANDS = Object.freeze({
17
+ /** The session's environment, for OMARCHY_PATH: the shell that runs, not the one on PATH. */
18
+ sessionEnvironment: Object.freeze({ command: "systemctl", args: Object.freeze(["--user", "show-environment"]) }),
19
+ /** Exit 0 while the compositor holds a session lock; the check `omarchy-restart-shell` makes. */
20
+ sessionLocked: Object.freeze({ command: "omarchy-hyprland-session-locked", args: Object.freeze([]) }),
21
+ /** Is the shell running. */
22
+ ping: Object.freeze({ command: "omarchy-shell", args: Object.freeze(["shell", "ping"]) }),
23
+ /** Every installed plugin with id, name, kinds, enabled and firstParty: `listPlugins` verbatim. */
24
+ listPlugins: Object.freeze({ command: "omarchy", args: Object.freeze(["plugin", "list", "--json"]) }),
25
+ /** The effective shell configuration, defaults filled in. */
26
+ listShellConfig: Object.freeze({ command: "omarchy-shell", args: Object.freeze(["shell", "listShellConfig"]) }),
27
+ /** Every manifest with its source directory, without the shell. */
28
+ catalog: Object.freeze({ command: "omarchy-plugin-catalog", args: Object.freeze([]) }),
29
+ /** The shell's pid: `qs list -p <shell dir> --json`, the two extra arguments passed at run time. */
30
+ shellPid: Object.freeze({ command: "qs", args: Object.freeze(["list", "-p"]) }),
31
+ /** The shell's IPC targets and their functions: `qs ipc -p <shell dir> show`, read-only, for the compatibility preflight. */
32
+ ipcShow: Object.freeze({ command: "qs", args: Object.freeze(["ipc", "-p"]) }),
33
+ /** The restart, and the readiness poll that comes with it. */
34
+ restartShell: Object.freeze({ command: "omarchy-restart-shell", args: Object.freeze([]) }),
35
+ /** Clock ticks per second, for /proc/<pid>/stat. */
36
+ clockTicks: Object.freeze({ command: "getconf", args: Object.freeze(["CLK_TCK"]) }),
37
+ })
38
+
39
+ /**
40
+ * Run one entry of the table. Resolves through the caller's PATH, so a test
41
+ * puts stubs first on it; never a shell, never a string.
42
+ *
43
+ * @param {keyof typeof COMMANDS} name
44
+ * @param {{ env?: NodeJS.ProcessEnv, extra?: string[], timeoutMs?: number }} [options]
45
+ * @returns {{ ok: boolean, status: number|null, stdout: string, stderr: string, missing: boolean }}
46
+ */
47
+ export function run(name, { env = process.env, extra = [], timeoutMs = 30_000 } = {}) {
48
+ const entry = COMMANDS[name]
49
+ if (!entry) throw new Error(`weigh: no command named ${name}`)
50
+ const result = spawnSync(entry.command, [...entry.args, ...extra], {
51
+ encoding: "utf8",
52
+ env,
53
+ timeout: timeoutMs,
54
+ stdio: ["ignore", "pipe", "pipe"],
55
+ })
56
+ return {
57
+ ok: result.status === 0 && !result.error,
58
+ status: result.status,
59
+ stdout: result.stdout || "",
60
+ stderr: result.stderr || "",
61
+ missing: result.error?.code === "ENOENT",
62
+ }
63
+ }
64
+
65
+ /** The command line an entry runs, for a message that names it. */
66
+ export function commandLine(name, extra = []) {
67
+ const entry = COMMANDS[name]
68
+ return [entry.command, ...entry.args, ...extra].join(" ")
69
+ }
@@ -0,0 +1,131 @@
1
+ // The shell configuration: where it is, the one transform `omakit weigh`
2
+ // applies to it, and the backup it restores from. docs/WEIGH.md, "The
3
+ // shell.json mutation", is the prose form of `without()` below; the two are
4
+ // held together by tests/unit/weigh.test.mjs.
5
+ //
6
+ // The transform is pure and works on the effective configuration the shell
7
+ // reports, not on the file. The file itself is touched in exactly two ways:
8
+ // its bytes are read once and written to a timestamped backup beside it, and
9
+ // those same bytes are written back over it on the way out. Nothing here
10
+ // re-serialises what the user wrote.
11
+
12
+ import { createHash } from "node:crypto"
13
+ import { existsSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs"
14
+ import { join } from "node:path"
15
+
16
+ /** Where Omarchy keeps the shell configuration: `~/.config/omarchy/shell.json`, the path the shell itself reads. */
17
+ export function configPaths(env = process.env) {
18
+ const dir = join(env.HOME || "", ".config/omarchy")
19
+ return { dir, file: join(dir, "shell.json") }
20
+ }
21
+
22
+ /** The md5 of a buffer, as hex: what is printed before and after, and compared. */
23
+ export function md5(bytes) {
24
+ return createHash("md5").update(bytes).digest("hex")
25
+ }
26
+
27
+ /**
28
+ * The effective configuration with a set of plugin ids removed: every bar
29
+ * layout entry with that id, every `plugins[]` entry with that id, and for
30
+ * a first-party id an entry in `disabledPlugins[]`, because a first-party
31
+ * plugin is enabled unless listed there. Nothing else in the document
32
+ * changes, and the input is not mutated.
33
+ *
34
+ * @param {object} config the effective configuration, `listShellConfig`
35
+ * @param {string[]} ids
36
+ * @param {{ id: string, firstParty?: boolean }[]} installed
37
+ */
38
+ export function without(config, ids, installed) {
39
+ const remove = new Set(ids)
40
+ const firstParty = new Set(installed.filter((plugin) => plugin.firstParty).map((plugin) => plugin.id))
41
+ const out = structuredClone(config)
42
+ const idOf = (entry) => (entry && typeof entry === "object" ? entry.id : entry)
43
+ if (out.bar && out.bar.layout && typeof out.bar.layout === "object") {
44
+ for (const section of Object.keys(out.bar.layout)) {
45
+ if (Array.isArray(out.bar.layout[section])) {
46
+ out.bar.layout[section] = out.bar.layout[section].filter((entry) => !remove.has(idOf(entry)))
47
+ }
48
+ }
49
+ }
50
+ if (Array.isArray(out.plugins)) out.plugins = out.plugins.filter((entry) => !remove.has(idOf(entry)))
51
+ const disabled = new Set(Array.isArray(out.disabledPlugins) ? out.disabledPlugins : [])
52
+ for (const id of ids) if (firstParty.has(id)) disabled.add(id)
53
+ if (disabled.size) out.disabledPlugins = [...disabled].sort()
54
+ else delete out.disabledPlugins
55
+ return out
56
+ }
57
+
58
+ /**
59
+ * Read the configuration file and write its bytes to a timestamped backup
60
+ * beside it. Returns what the restore needs: the bytes, the backup path and
61
+ * the md5. A missing file is recorded as such and restored by removal.
62
+ *
63
+ * @param {string} configFile
64
+ * @param {string} stamp UTC, `YYYYMMDDHHMMSS`
65
+ */
66
+ export function backupConfig(configFile, stamp) {
67
+ let bytes = null
68
+ try {
69
+ bytes = readFileSync(configFile)
70
+ } catch (error) {
71
+ if (error.code !== "ENOENT") throw error
72
+ }
73
+ const backupFile = `${configFile}.omakit-backup-${stamp}`
74
+ // The same mode as the original: shell.json is 0600 on an Omarchy install,
75
+ // and a copy of a private file is a private file.
76
+ if (bytes !== null) writeFileSync(backupFile, bytes, { mode: statSync(configFile).mode & 0o777 })
77
+ return { configFile, backupFile, bytes, md5Before: bytes === null ? null : md5(bytes) }
78
+ }
79
+
80
+ /**
81
+ * Write one configuration for a run. The document is serialised the way
82
+ * `jq .` would, two-space indented, so the shell reads exactly what the
83
+ * transform produced.
84
+ */
85
+ export function writeConfig(configFile, config) {
86
+ writeFileSync(configFile, `${JSON.stringify(config, null, 2)}\n`)
87
+ }
88
+
89
+ /**
90
+ * Put the user's bytes back. Verification is a separate step, after the
91
+ * shell has been restarted on them: a shell that rewrites the file as it
92
+ * starts is exactly what the md5 has to catch, so it is read after the
93
+ * restart and not before.
94
+ *
95
+ * @param {{ configFile: string, bytes: Buffer|null }} backup
96
+ */
97
+ export function restoreConfig(backup) {
98
+ const { configFile, bytes } = backup
99
+ if (bytes === null) {
100
+ try {
101
+ unlinkSync(configFile)
102
+ } catch {
103
+ // It was not there before and is not there now.
104
+ }
105
+ return
106
+ }
107
+ writeFileSync(configFile, bytes)
108
+ }
109
+
110
+ /**
111
+ * Are the user's bytes back: the md5 of what is on disk now against the md5
112
+ * of the backup. The backup is removed only when they are equal; otherwise
113
+ * it stays and the caller says so.
114
+ *
115
+ * @param {{ configFile: string, backupFile: string, bytes: Buffer|null, md5Before: string|null }} backup
116
+ * @returns {{ md5After: string|null, restored: boolean, backupRemoved: boolean }}
117
+ */
118
+ export function verifyRestore(backup) {
119
+ const { configFile, backupFile, bytes, md5Before } = backup
120
+ if (bytes === null) return { md5After: null, restored: !existsSync(configFile), backupRemoved: false }
121
+ let md5After = null
122
+ try {
123
+ md5After = md5(readFileSync(configFile))
124
+ } catch {
125
+ md5After = null
126
+ }
127
+ const restored = md5After === md5Before
128
+ if (restored) unlinkSync(backupFile)
129
+ return { md5After, restored, backupRemoved: restored }
130
+ }
131
+
@@ -0,0 +1,32 @@
1
+ // The one question `omakit weigh` asks: may it restart the shell that many
2
+ // times. Asked at a terminal only, on stderr so stdout stays the report;
3
+ // anywhere else, a pipe, an agent, --json, the answer has to arrive as
4
+ // --yes, and cli.mjs refuses with a usage error when it does not. Nothing
5
+ // typed here is written anywhere.
6
+
7
+ import { createInterface } from "node:readline"
8
+ import { action, colourEnabled, styler } from "../marketplace/style.mjs"
9
+
10
+ /**
11
+ * @param {{ input?: NodeJS.ReadStream, output?: NodeJS.WriteStream, colour?: boolean, question: string }} options
12
+ * @returns {Promise<boolean>} true only for `y` or `yes`, in any case; the end of stdin is no
13
+ */
14
+ export function askYes({ input = process.stdin, output = process.stderr, colour = colourEnabled(output), question }) {
15
+ const c = styler(colour)
16
+ return new Promise((resolve) => {
17
+ const rl = createInterface({ input, terminal: false })
18
+ let answered = false
19
+ const settle = (value) => {
20
+ if (answered) return
21
+ answered = true
22
+ rl.close()
23
+ resolve(value)
24
+ }
25
+ rl.on("line", (line) => settle(/^y(?:es)?$/i.test(line.trim())))
26
+ rl.on("close", () => {
27
+ if (!answered) output.write("\n")
28
+ settle(false)
29
+ })
30
+ output.write(`${action(`${question} [y/N]:`, c, { indent: 0 })[0]} `)
31
+ })
32
+ }
@@ -0,0 +1,167 @@
1
+ // The JSON contract of docs/WEIGH.md, executable. `validateWeighDocument()`
2
+ // returns every way a document departs from it, as sentences, and an empty
3
+ // list when it does not. tests/unit/weigh.test.mjs holds every produced
4
+ // document to it, and the lab scenario runs it over the document a real
5
+ // shell produced, so the prose and the code cannot drift apart unnoticed.
6
+ //
7
+ // Run as a program: `node tools/weigh/contract.mjs <document.json>` prints
8
+ // the problems and exits 1 on any.
9
+
10
+ import { readFileSync } from "node:fs"
11
+ import { resolve } from "node:path"
12
+ import { pathToFileURL } from "node:url"
13
+ import { tickPercent, verdict as verdictOf } from "./stats.mjs"
14
+
15
+ const VERDICTS = new Set(["within-noise", "above-noise", "unknown"])
16
+
17
+ function isStats(value, at, problems) {
18
+ if (!value || typeof value !== "object") {
19
+ problems.push(`${at} is not a stats object`)
20
+ return
21
+ }
22
+ for (const key of ["median", "spread", "min", "max"]) {
23
+ if (!(key in value) || (value[key] !== null && typeof value[key] !== "number")) problems.push(`${at}.${key} is neither a number nor null`)
24
+ }
25
+ if (!Array.isArray(value.runs) || value.runs.some((run) => typeof run !== "number")) problems.push(`${at}.runs is not a list of numbers`)
26
+ if (Array.isArray(value.runs) && value.runs.length === 0 && value.median !== null) problems.push(`${at} has no runs but a median`)
27
+ }
28
+
29
+ function isSample(sample, at, problems) {
30
+ for (const key of ["label", "started", "ended"]) if (typeof sample[key] !== "string") problems.push(`${at}.${key} is not a string`)
31
+ for (const key of ["run", "shellPid", "readyAfterSeconds", "windowSeconds"]) if (typeof sample[key] !== "number") problems.push(`${at}.${key} is not a number`)
32
+ if ("configRewritten" in sample && sample.configRewritten !== null && typeof sample.configRewritten !== "boolean") problems.push(`${at}.configRewritten is neither a boolean nor null`)
33
+ const shell = sample.shell
34
+ if (!shell || typeof shell !== "object") {
35
+ problems.push(`${at}.shell is missing`)
36
+ return
37
+ }
38
+ for (const key of ["pssKb", "rssKb", "pssKbSettled", "rssKbSettled"]) if (shell[key] !== null && typeof shell[key] !== "number") problems.push(`${at}.shell.${key} is neither a number nor null`)
39
+ if (shell.memoryAt !== "window-end") problems.push(`${at}.shell.memoryAt is not "window-end"`)
40
+ if (!Array.isArray(shell.trace)) problems.push(`${at}.shell.trace is not a list`)
41
+ else for (const [index, point] of shell.trace.entries()) {
42
+ if (typeof point.t !== "number") problems.push(`${at}.shell.trace[${index}].t is not a number`)
43
+ for (const key of ["pssKb", "rssKb"]) if (point[key] !== null && typeof point[key] !== "number") problems.push(`${at}.shell.trace[${index}].${key} is neither a number nor null`)
44
+ }
45
+ for (const key of ["cpuTicksStart", "cpuTicksEnd", "cpuSeconds", "cpuPercent", "reapedChildTicksStart", "reapedChildTicksEnd", "reapedChildCpuSeconds", "reapedChildCpuPercent"]) {
46
+ if (typeof shell[key] !== "number") problems.push(`${at}.shell.${key} is not a number`)
47
+ }
48
+ if (!Array.isArray(sample.children)) {
49
+ problems.push(`${at}.children is not a list`)
50
+ return
51
+ }
52
+ for (const [index, child] of sample.children.entries()) {
53
+ for (const key of ["pid", "firstSeen", "lastSeen", "cpuFirst", "cpuLast", "rssLast", "samples"]) if (typeof child[key] !== "number") problems.push(`${at}.children[${index}].${key} is not a number`)
54
+ for (const key of ["comm", "arg0"]) if (typeof child[key] !== "string") problems.push(`${at}.children[${index}].${key} is not a string`)
55
+ if (typeof child.arg0 === "string" && child.arg0.length > 80) problems.push(`${at}.children[${index}].arg0 is longer than 80 characters`)
56
+ if ("key" in child) problems.push(`${at}.children[${index}] carries the full command line, which never reaches the file`)
57
+ }
58
+ }
59
+
60
+ /**
61
+ * @param {object} document
62
+ * @returns {string[]} problems; empty when the document follows the contract
63
+ */
64
+ export function validateWeighDocument(document) {
65
+ const problems = []
66
+ if (!document || typeof document !== "object") return ["the document is not an object"]
67
+ if (typeof document.omakit !== "string") problems.push("omakit is not a string")
68
+ if (document.command !== "weigh") problems.push('command is not "weigh"')
69
+ for (const key of ["method", "started", "ended", "host", "out"]) if (typeof document[key] !== "string") problems.push(`${key} is not a string`)
70
+ if (!document.shell || typeof document.shell.version !== "string" || typeof document.shell.omarchyPath !== "string") problems.push("shell lacks version and omarchyPath")
71
+ const settings = document.settings || {}
72
+ for (const key of ["runs", "windowSeconds", "settleSeconds", "readyTimeoutSeconds", "sampleIntervalMs", "clockTicksPerSecond"]) {
73
+ if (typeof settings[key] !== "number") problems.push(`settings.${key} is not a number`)
74
+ }
75
+ const config = document.config || {}
76
+ for (const key of ["path", "backup"]) if (typeof config[key] !== "string") problems.push(`config.${key} is not a string`)
77
+ for (const key of ["md5Before", "md5After"]) if (config[key] !== null && !/^[0-9a-f]{32}$/.test(String(config[key]))) problems.push(`config.${key} is not an md5`)
78
+ if (typeof config.restored !== "boolean") problems.push("config.restored is not a boolean")
79
+ if (config.restored !== (config.md5After === config.md5Before)) problems.push("config.restored disagrees with the two md5s")
80
+ if (!Array.isArray(document.audited) || document.audited.some((id) => typeof id !== "string")) problems.push("audited is not a list of ids")
81
+ if (!Array.isArray(document.failedRuns)) problems.push("failedRuns is not a list")
82
+
83
+ const baseline = document.baseline
84
+ if (!baseline || typeof baseline !== "object") {
85
+ problems.push("baseline is missing")
86
+ } else {
87
+ if (!baseline.config || typeof baseline.config !== "object") problems.push("baseline.config is not the configuration the baseline ran with")
88
+ for (const key of ["pssMb", "rssMb", "pssMbSettled", "rssMbSettled", "cpuPercent", "childRssMb"]) isStats(baseline[key], `baseline.${key}`, problems)
89
+ if (!Array.isArray(baseline.runs)) problems.push("baseline.runs is not a list")
90
+ else for (const [index, sample] of baseline.runs.entries()) isSample(sample, `baseline.runs[${index}]`, problems)
91
+ }
92
+ const floor = document.noiseFloor
93
+ if (!floor || typeof floor !== "object") {
94
+ problems.push("noiseFloor is missing")
95
+ } else {
96
+ for (const key of ["pssMb", "rssMb", "pssMbSettled", "rssMbSettled", "cpuPercent"]) if (floor[key] !== null && typeof floor[key] !== "number") problems.push(`noiseFloor.${key} is neither a number nor null`)
97
+ if (typeof floor.origin !== "string") problems.push("noiseFloor.origin is not a string")
98
+ // The floor is the baseline spread over two or more runs, and null for
99
+ // fewer: one run has no spread, and a spread of zero would read as a
100
+ // floor everything clears.
101
+ const enough = Array.isArray(baseline?.runs) && baseline.runs.length >= 2
102
+ if (baseline?.pssMb && floor.pssMb !== (enough ? baseline.pssMb.spread : null)) problems.push(enough ? "noiseFloor.pssMb is not the baseline Pss spread" : "noiseFloor.pssMb is set from fewer than two baseline runs")
103
+ if (baseline?.cpuPercent && floor.cpuPercent !== (enough ? baseline.cpuPercent.spread : null)) problems.push(enough ? "noiseFloor.cpuPercent is not the baseline CPU spread" : "noiseFloor.cpuPercent is set from fewer than two baseline runs")
104
+ }
105
+
106
+ if (!Array.isArray(document.plugins)) {
107
+ problems.push("plugins is not a list")
108
+ return problems
109
+ }
110
+ if (Array.isArray(document.audited) && document.plugins.length !== document.audited.length) problems.push("plugins has a row count other than audited")
111
+ for (const [index, plugin] of document.plugins.entries()) {
112
+ const at = `plugins[${index}]`
113
+ for (const key of ["id", "name", "origin"]) if (typeof plugin[key] !== "string") problems.push(`${at}.${key} is not a string`)
114
+ if (!Array.isArray(plugin.kinds)) problems.push(`${at}.kinds is not a list`)
115
+ if (typeof plugin.firstParty !== "boolean") problems.push(`${at}.firstParty is not a boolean`)
116
+ if (typeof plugin.runsCompleted !== "number") problems.push(`${at}.runsCompleted is not a number`)
117
+ for (const key of ["shellPssMb", "shellRssMb", "shellPssMbSettled", "shellCpuPercent", "childRssMb", "childCpuPercent", "childSpawns", "unattributedChildren"]) isStats(plugin[key], `${at}.${key}`, problems)
118
+ if (!Array.isArray(plugin.unattributedCommands) || plugin.unattributedCommands.some((command) => typeof command !== "string" || command.length > 100)) problems.push(`${at}.unattributedCommands is not a list of short commands`)
119
+ for (const key of ["totalMb", "totalCpuPercent"]) if (plugin[key] !== null && typeof plugin[key] !== "number") problems.push(`${at}.${key} is neither a number nor null`)
120
+ const verdict = plugin.verdict || {}
121
+ for (const key of ["memory", "cpu"]) if (!VERDICTS.has(verdict[key])) problems.push(`${at}.verdict.${key} is not a verdict`)
122
+ if (typeof verdict.summary !== "string") problems.push(`${at}.verdict.summary is not a string`)
123
+ if (plugin.shellPssMb?.median !== undefined && floor?.pssMb !== undefined) {
124
+ const expected = verdictOf(plugin.shellPssMb.median, floor.pssMb)
125
+ if (verdict.memory !== expected) problems.push(`${at}.verdict.memory is ${verdict.memory}; the median and the floor say ${expected}`)
126
+ }
127
+ if (plugin.shellCpuPercent?.median !== undefined && floor?.cpuPercent !== undefined) {
128
+ const expected = verdictOf(plugin.shellCpuPercent.median, floor.cpuPercent, tickPercent(settings.clockTicksPerSecond, settings.windowSeconds))
129
+ if (verdict.cpu !== expected) problems.push(`${at}.verdict.cpu is ${verdict.cpu}; the median and the floor say ${expected}`)
130
+ }
131
+ const within = plugin.withinNoise || {}
132
+ for (const key of ["pss", "rss", "cpu", "ownPss", "ownCpu"]) if (within[key] !== null && typeof within[key] !== "boolean") problems.push(`${at}.withinNoise.${key} is neither a boolean nor null`)
133
+ for (const key of ["baselinePssSpreadMb", "baselineCpuSpreadPercent", "cpuTickPercent"]) if (within[key] !== null && typeof within[key] !== "number") problems.push(`${at}.withinNoise.${key} is neither a number nor null`)
134
+ if (typeof within.note !== "string") problems.push(`${at}.withinNoise.note is not a string`)
135
+ if (plugin.readme !== null && typeof plugin.readme !== "string") problems.push(`${at}.readme is neither a string nor null`)
136
+ if (typeof plugin.readme === "string" && !/^Weighs (?:nothing measurable: no CPU above the floor \([\d.?]+%\) and no child process|(?:[\d.]+% CPU|no CPU above the floor \([\d.?]+%\)) and runs (?:no child process|\d+ child process(?:es)? using [\d.]+ MB and [\d.]+% CPU)), on Omarchy .+, measured with omakit weigh on \d{4}-\d{2}-\d{2}$/.test(plugin.readme)) {
137
+ problems.push(`${at}.readme is not the README sentence: ${plugin.readme}`)
138
+ }
139
+ if ((verdict.cpu === "unknown") !== (plugin.readme === null)) problems.push(`${at}.readme is ${plugin.readme === null ? "null with a verdict" : "present without one"}`)
140
+ if (typeof plugin.readme === "string" && /\bMB\b(?!.*child process)/.test(plugin.readme.split(" and runs ")[0])) problems.push(`${at}.readme speaks about the shell's memory`)
141
+ if (!Array.isArray(plugin.deltas)) {
142
+ problems.push(`${at}.deltas is not a list`)
143
+ } else {
144
+ if (plugin.deltas.length !== plugin.runsCompleted) problems.push(`${at}.deltas has ${plugin.deltas.length} entries for ${plugin.runsCompleted} completed runs`)
145
+ for (const [run, delta] of plugin.deltas.entries()) {
146
+ for (const key of ["run", "shellPssMb", "shellRssMb", "shellCpuPercent", "childRssMb", "childCpuPercent", "reapedChildCpuPercent", "childSpawns"]) {
147
+ if (typeof delta[key] !== "number") problems.push(`${at}.deltas[${run}].${key} is not a number`)
148
+ }
149
+ if (!Array.isArray(delta.children)) problems.push(`${at}.deltas[${run}].children is not a list`)
150
+ if (!Array.isArray(delta.unattributedChildren) || delta.unattributedChildren.some((child) => typeof child.comm !== "string" || typeof child.arg0 !== "string" || "key" in child)) problems.push(`${at}.deltas[${run}].unattributedChildren is not a list of {comm, arg0}`)
151
+ }
152
+ }
153
+ if (!Array.isArray(plugin.runs)) problems.push(`${at}.runs is not a list`)
154
+ else for (const [run, sample] of plugin.runs.entries()) isSample(sample, `${at}.runs[${run}]`, problems)
155
+ }
156
+ return problems
157
+ }
158
+
159
+ const invoked = process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href
160
+ if (invoked) {
161
+ const input = process.argv[2]
162
+ if (!input) throw new Error("usage: node tools/weigh/contract.mjs <document.json>")
163
+ const problems = validateWeighDocument(JSON.parse(readFileSync(resolve(input), "utf8")))
164
+ for (const problem of problems) process.stdout.write(`${problem}\n`)
165
+ process.stdout.write(problems.length ? `${problems.length} problem(s)\n` : "ok: the document follows the contract in docs/WEIGH.md\n")
166
+ process.exit(problems.length ? 1 : 0)
167
+ }
@@ -0,0 +1,150 @@
1
+ // Reading /proc, and nothing else. Every function takes the root of the
2
+ // tree, `/proc` on a real machine and a directory of fixtures in
3
+ // tests/unit/weigh.test.mjs, so the sampler is tested without a shell.
4
+ //
5
+ // What is read, and from where, is the origin every figure carries:
6
+ //
7
+ // /proc/<pid>/stat utime+stime (the shell's own CPU), cutime+cstime
8
+ // (the CPU of every child it has reaped), ppid
9
+ // /proc/<pid>/status VmRSS
10
+ // /proc/<pid>/smaps_rollup Pss: a shared page counted once, divided among
11
+ // the processes that share it
12
+ // /proc/<pid>/cmdline the first argument, cut to 80 characters, and
13
+ // the full line as a key that never leaves the run
14
+ //
15
+ // A process that disappears between the directory listing and the read is
16
+ // skipped, not an error: a poller lives for milliseconds.
17
+
18
+ import { readdirSync, readFileSync } from "node:fs"
19
+ import { join } from "node:path"
20
+
21
+ export const PROC = "/proc"
22
+
23
+ /** How much of a child's first argument reaches the output: enough to tell `-m` from `status`, never a whole token. */
24
+ export const ARG0_CHARS = 80
25
+
26
+ /**
27
+ * The fields of /proc/<pid>/stat this tool reads, by their 1-based position
28
+ * in the man page: comm is field 2 and may contain spaces, so everything
29
+ * after its closing parenthesis is split on spaces and counted from field 3.
30
+ */
31
+ function statOf(root, pid) {
32
+ let text
33
+ try {
34
+ text = readFileSync(join(root, String(pid), "stat"), "utf8")
35
+ } catch {
36
+ return null
37
+ }
38
+ const close = text.lastIndexOf(")")
39
+ const open = text.indexOf("(")
40
+ if (open < 0 || close < 0) return null
41
+ const rest = text.slice(close + 2).split(" ")
42
+ // rest[0] is field 3 (state); field n is rest[n - 3].
43
+ const field = (n) => Number(rest[n - 3])
44
+ return {
45
+ pid: Number(text.slice(0, open).trim()),
46
+ comm: text.slice(open + 1, close),
47
+ ppid: field(4),
48
+ utime: field(14),
49
+ stime: field(15),
50
+ cutime: field(16),
51
+ cstime: field(17),
52
+ }
53
+ }
54
+
55
+ /** utime + stime, in clock ticks, or null when the process is gone. */
56
+ export function cpuTicks(root, pid) {
57
+ const stat = statOf(root, pid)
58
+ return stat ? stat.utime + stat.stime : null
59
+ }
60
+
61
+ /** cutime + cstime: the CPU of every child the process has reaped, in clock ticks. */
62
+ export function childTicks(root, pid) {
63
+ const stat = statOf(root, pid)
64
+ return stat ? stat.cutime + stat.cstime : null
65
+ }
66
+
67
+ /** One `Key: value kB` line out of status or smaps_rollup, in kB, or null. */
68
+ function kbField(root, pid, file, key) {
69
+ let text
70
+ try {
71
+ text = readFileSync(join(root, String(pid), file), "utf8")
72
+ } catch {
73
+ return null
74
+ }
75
+ const match = text.match(new RegExp(`^${key}:\\s+(\\d+)\\s+kB`, "m"))
76
+ return match ? Number(match[1]) : null
77
+ }
78
+
79
+ /** VmRSS from /proc/<pid>/status, in kB. */
80
+ export function rssKb(root, pid) {
81
+ return kbField(root, pid, "status", "VmRSS")
82
+ }
83
+
84
+ /** Pss from /proc/<pid>/smaps_rollup, in kB; null where the kernel does not offer the file. */
85
+ export function pssKb(root, pid) {
86
+ return kbField(root, pid, "smaps_rollup", "Pss")
87
+ }
88
+
89
+ /** The first argument and the full command line of a process, from /proc/<pid>/cmdline. */
90
+ function cmdlineOf(root, pid) {
91
+ let text
92
+ try {
93
+ text = readFileSync(join(root, String(pid), "cmdline"), "utf8")
94
+ } catch {
95
+ return { arg0: "", key: "" }
96
+ }
97
+ const parts = text.split("\0").filter((part, index, all) => index < all.length - 1 || part !== "")
98
+ return {
99
+ arg0: (parts[1] || "").slice(0, ARG0_CHARS).replace(/\t/g, " "),
100
+ key: parts.join(" ").replace(/\t/g, " "),
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Every descendant of `rootPid`, one sample: pid, command name, first
106
+ * argument, the full command line as a key, CPU ticks and VmRSS. The key
107
+ * tells two `inotifywait -m` watchers apart during the run; it is used for
108
+ * attribution and never written to the output.
109
+ *
110
+ * @param {string} root
111
+ * @param {number} rootPid
112
+ * @returns {{ pid: number, comm: string, arg0: string, key: string, cpu: number, rss: number }[]}
113
+ */
114
+ export function descendants(root, rootPid) {
115
+ const parent = new Map()
116
+ const name = new Map()
117
+ let entries
118
+ try {
119
+ entries = readdirSync(root)
120
+ } catch {
121
+ return []
122
+ }
123
+ for (const entry of entries) {
124
+ if (!/^\d+$/.test(entry)) continue
125
+ const stat = statOf(root, Number(entry))
126
+ if (!stat) continue
127
+ parent.set(stat.pid, stat.ppid)
128
+ name.set(stat.pid, stat.comm)
129
+ }
130
+ const out = []
131
+ for (const [pid] of parent) {
132
+ let up = parent.get(pid)
133
+ let depth = 0
134
+ while (up !== undefined && up !== 0 && up !== rootPid && depth < 32) {
135
+ up = parent.get(up)
136
+ depth += 1
137
+ }
138
+ if (up !== rootPid) continue
139
+ const { arg0, key } = cmdlineOf(root, pid)
140
+ out.push({
141
+ pid,
142
+ comm: name.get(pid),
143
+ arg0,
144
+ key: key || name.get(pid),
145
+ cpu: cpuTicks(root, pid) ?? 0,
146
+ rss: rssKb(root, pid) ?? 0,
147
+ })
148
+ }
149
+ return out.sort((a, b) => a.pid - b.pid)
150
+ }