omakit 0.4.1 → 0.4.3

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,194 @@
1
+ // Write sites: a `FileView {` with a path and a write adapter or a write
2
+ // call in QML; a redirect, `tee`, `cp`, `mv`, `mkdir`, `mktemp`, `install`
3
+ // or `touch` in shell; `writeFile` and its variants in JavaScript; `open()`
4
+ // for writing in Python. Each row says whether the path's literal prefix,
5
+ // after the `$HOME`, `~` and `XDG_*` idioms are expanded, falls under a
6
+ // directory the plugin controls, and which mode the file shows for it.
7
+
8
+ import { basename, blankComments, blocks, closingBracket, lineOf, propertyValue, blankShellExpressions, shellLogicalLines, shellPieces, shellWords, stringLiteral, withoutRedirections } from "./text.mjs"
9
+
10
+ /** The directories a plugin controls, by the names the contract lists. */
11
+ export const CONTROLLED = Object.freeze(["$XDG_STATE_HOME", "$XDG_CACHE_HOME", "$XDG_RUNTIME_DIR"])
12
+ export const SHARED_TEMP = Object.freeze(["/tmp", "/var/tmp", "/dev/shm"])
13
+ const DEVICES = new Set(["/dev/null", "/dev/stderr", "/dev/stdout", "/dev/tty"])
14
+
15
+ /**
16
+ * A path's literal prefix in canonical form: `~` and `$HOME` idioms,
17
+ * `${X}` and `Quickshell.env("X")` all read as `$X`, and the three
18
+ * dot-directories under the home read as the XDG names they default to.
19
+ * A JavaScript expression that is a `+` chain of string literals and
20
+ * `Quickshell.env()` calls is joined; any other expression is returned as
21
+ * `null`, which the classification reads as unknown.
22
+ */
23
+ export function canonicalPath(raw) {
24
+ let text = String(raw).trim()
25
+ const literal = stringLiteral(text)
26
+ if (literal !== null) text = literal
27
+ else if (/[+]/.test(text) || /Quickshell\.env|StandardPaths/.test(text)) {
28
+ const pieces = text.split(/\s*\+\s*/)
29
+ const joined = pieces.map((piece) => {
30
+ const part = stringLiteral(piece)
31
+ if (part !== null) return part
32
+ const env = piece.match(/^Quickshell\.env\(\s*(["'])([A-Z_][A-Z0-9_]*)\1\s*\)$/)
33
+ if (env) return `$${env[2]}`
34
+ return null
35
+ })
36
+ if (joined.some((piece) => piece === null)) return null
37
+ text = joined.join("")
38
+ } else if (!/^[~/$.]/.test(text) && !/^[\w.-]+(?:\/[\w.-]*)*$/.test(text)) return null
39
+ text = text.replace(/\$\{(\w+)\}/g, "$$$1")
40
+ if (text === "~" || text.startsWith("~/")) text = `$HOME${text.slice(1)}`
41
+ text = text.replace(/^\$HOME\/\.local\/state(?=\/|$)/, "$XDG_STATE_HOME")
42
+ .replace(/^\$HOME\/\.cache(?=\/|$)/, "$XDG_CACHE_HOME")
43
+ .replace(/^\$HOME\/\.config(?=\/|$)/, "$XDG_CONFIG_HOME")
44
+ return text
45
+ }
46
+
47
+ /**
48
+ * Whether a canonical path is under a directory the plugin controls.
49
+ * @returns {{ controlledDirectory: "observed"|"not-observed"|"unknown", controlledBy: string|null, temp: boolean }}
50
+ */
51
+ export function classifyPath(path, pluginId) {
52
+ if (path === null || path === undefined) return { controlledDirectory: "unknown", controlledBy: null, temp: false }
53
+ for (const prefix of CONTROLLED) {
54
+ if (path === prefix || path.startsWith(`${prefix}/`)) return { controlledDirectory: "observed", controlledBy: prefix, temp: false }
55
+ }
56
+ const own = pluginId ? `$XDG_CONFIG_HOME/omarchy/plugins/${pluginId}` : null
57
+ if (own && (path === own || path.startsWith(`${own}/`))) return { controlledDirectory: "observed", controlledBy: own, temp: false }
58
+ for (const prefix of SHARED_TEMP) {
59
+ if (path === prefix || path.startsWith(`${prefix}/`)) return { controlledDirectory: "not-observed", controlledBy: null, temp: true }
60
+ }
61
+ if (path.startsWith("/") || path.startsWith("$HOME") || path.startsWith("$XDG_")) return { controlledDirectory: "not-observed", controlledBy: null, temp: false }
62
+ return { controlledDirectory: "unknown", controlledBy: null, temp: false }
63
+ }
64
+
65
+ function row(file, line, rawPath, via, pluginId, mode = null) {
66
+ const canonical = canonicalPath(rawPath)
67
+ const classified = classifyPath(canonical, pluginId)
68
+ return { file: file.path, line, path: String(rawPath).trim(), canonicalPath: canonical, via, ...classified, mode }
69
+ }
70
+
71
+ function qmlWrites(file, pluginId) {
72
+ const text = blankComments(file.text)
73
+ const rows = []
74
+ for (const block of blocks(text, "FileView")) {
75
+ const path = propertyValue(block.body, "path")
76
+ if (!path) continue
77
+ const writes = /writeAdapter|blockWrites|atomicWrites|setText\s*\(|(?<![\w.])write\s*\(/.test(block.body)
78
+ || (block.id && new RegExp(`(?<![\\w.])${block.id}\\.(?:writeAdapter|setText|write)\\s*\\(`).test(text))
79
+ if (!writes) continue
80
+ rows.push(row(file, lineOf(text, block.open + 1 + path.offset), path.text, "FileView", pluginId))
81
+ }
82
+ rows.push(...jsWrites({ ...file, text }, pluginId, true))
83
+ return rows
84
+ }
85
+
86
+ function jsWrites(file, pluginId, blanked = false) {
87
+ const text = blanked ? file.text : blankComments(file.text)
88
+ const rows = []
89
+ for (const match of text.matchAll(/(?<![\w.])(writeFileSync|writeFile|appendFileSync|appendFile)\s*\(/g)) {
90
+ const open = match.index + match[0].length - 1
91
+ const end = closingBracket(text, open)
92
+ if (end < 0) continue
93
+ const first = text.slice(open + 1, end).split(",")[0]
94
+ rows.push(row(file, lineOf(text, match.index), first, match[1], pluginId))
95
+ }
96
+ return rows
97
+ }
98
+
99
+ function pythonWrites(file, pluginId) {
100
+ const rows = []
101
+ const lines = file.text.split("\n")
102
+ for (const [index, line] of lines.entries()) {
103
+ const code = line.split("#")[0]
104
+ for (const match of code.matchAll(/(?<![\w.])open\s*\(\s*([^,()]+)\s*,\s*(["'])([rwaxb+]+)\2/g)) {
105
+ if (!/[wax]/.test(match[3])) continue
106
+ rows.push(row(file, index + 1, match[1], "open", pluginId))
107
+ }
108
+ }
109
+ return rows
110
+ }
111
+
112
+ /** The words that are not options, and the value of `-m`/`--mode` when one is given. */
113
+ function operands(all) {
114
+ const words = withoutRedirections(all)
115
+ const out = []
116
+ let mode = null
117
+ for (let index = 1; index < words.length; index += 1) {
118
+ const word = words[index]
119
+ if (word === "-m" || word === "--mode") {
120
+ mode = words[index + 1] ?? null
121
+ index += 1
122
+ } else if (word.startsWith("--mode=")) mode = word.slice("--mode=".length)
123
+ else if (word.startsWith("-") && word !== "-") continue
124
+ else out.push(word)
125
+ }
126
+ return { operands: out, mode }
127
+ }
128
+
129
+ function shellWrites(file, pluginId) {
130
+ const rows = []
131
+ const chmods = []
132
+ let umask = null
133
+ for (const { line, text } of shellLogicalLines(file.text)) {
134
+ const trimmed = blankShellExpressions(text).trim()
135
+ if (!trimmed || trimmed.startsWith("#")) continue
136
+ for (const segment of shellPieces(trimmed)) {
137
+ const words = shellWords(segment.text)
138
+ if (!words.length) continue
139
+ // Redirections anywhere in the segment: `> path`, `>> path`, `>path`, `&> path`.
140
+ for (let i = 0; i < words.length; i += 1) {
141
+ const word = words[i]
142
+ let target = null
143
+ let via = null
144
+ if (/^(?:\d*>>?|&>>?)$/.test(word) && !/^\d*>&/.test(word)) {
145
+ target = words[i + 1]
146
+ via = word.includes(">>") ? ">>" : ">"
147
+ i += 1
148
+ } else if (/^(?:\d*>>?|&>>?)[^&\s]/.test(word)) {
149
+ target = word.replace(/^(?:\d*>>?|&>>?)/, "")
150
+ via = word.includes(">>") ? ">>" : ">"
151
+ }
152
+ // A subshell's closing paren clings to the last word; a bare number
153
+ // or operator where a path would be is a comparison the blanking
154
+ // did not reach, not a write.
155
+ if (target) target = target.replace(/[);]+$/, "")
156
+ if (target && !DEVICES.has(target) && !/^&\d$/.test(target) && !/^[\d=<>!]+$/.test(target)) rows.push(row(file, line, target, via, pluginId))
157
+ }
158
+ const command = basename(words[0])
159
+ if (command === "umask" && words[1]) umask = words[1]
160
+ if (command === "chmod" && words.length >= 3) chmods.push({ mode: words[1], paths: words.slice(2) })
161
+ if (["tee", "mkdir", "touch"].includes(command)) {
162
+ const { operands: paths, mode } = operands(words)
163
+ for (const path of paths) rows.push(row(file, line, path, command, pluginId, mode))
164
+ } else if (["cp", "mv", "install", "ln"].includes(command)) {
165
+ const { operands: paths, mode } = operands(words)
166
+ if (paths.length >= 2) rows.push(row(file, line, paths[paths.length - 1], command, pluginId, mode))
167
+ } else if (command === "mktemp") {
168
+ const { operands: paths } = operands(words)
169
+ const template = paths[0] || (words.includes("-p") ? `${words[words.indexOf("-p") + 1]}/tmp.XXXXXX` : "/tmp/tmp.XXXXXX")
170
+ rows.push(row(file, line, template, "mktemp", pluginId, words.includes("-d") ? "0700" : "0600"))
171
+ }
172
+ }
173
+ }
174
+ for (const write of rows) {
175
+ if (write.mode) continue
176
+ const chmod = chmods.find((entry) => entry.paths.includes(write.path))
177
+ if (chmod) write.mode = `chmod ${chmod.mode}`
178
+ else if (umask) write.mode = `umask ${umask}`
179
+ }
180
+ return rows
181
+ }
182
+
183
+ /**
184
+ * @param {{ path: string, kind: string, text: string }} file
185
+ * @param {{ pluginId?: string|null }} [context]
186
+ * @returns {Array} write rows, in file order
187
+ */
188
+ export function extractWrites(file, { pluginId = null } = {}) {
189
+ if (file.kind === "qml") return qmlWrites(file, pluginId)
190
+ if (file.kind === "js") return jsWrites(file, pluginId)
191
+ if (file.kind === "shell") return shellWrites(file, pluginId)
192
+ if (file.kind === "python") return pythonWrites(file, pluginId)
193
+ return []
194
+ }
@@ -8,7 +8,7 @@ local commit through the transport seam the marketplace tests itself
8
8
  | File | Purpose |
9
9
  | --- | --- |
10
10
  | `pin.mjs` | The pin identity (one home) and the reproducible setup: `omakit pin` fetches exactly that commit into `$XDG_CACHE_HOME/omakit/marketplace` and refuses a modified checkout. |
11
- | `local-transport.mjs` | Answers the four request shapes the official resolver makes, from a local clone at the exact commit. No network, no credentials, no writes. |
11
+ | `local-transport.mjs` | Answers the four request shapes the official resolver makes, from a local clone at the exact commit. No network, no credentials, no writes. With `subdir`, serves a directory below the root as the whole tree, which is how `inspect` keeps the baseline to the plugin's own tree. |
12
12
  | `run-baseline.mjs` | Runs the pinned official baseline over either transport and reports the pin identity beside the result. |
13
13
  | `verify.mjs` | Builds the `marketplaceBaseline` section: pin, transport, assumptions, the official result verbatim, the statement. `omakit verify` renders it for a person (`renderVerify` in `report.mjs`) and prints the document itself behind `--json` and `--out`. |
14
14
  | `preflight.mjs` | Translates that result into what it will cause on submission, using the pinned policy, and renders the marketplace's own report text with its attestation marker stripped and asserted absent. |
@@ -18,6 +18,8 @@ local commit through the transport seam the marketplace tests itself
18
18
  | `tree.mjs` | The installable tree of a subject at one exact commit, from the Git object database. |
19
19
  | `plugin.mjs` | The root files the submission contract needs, and the declared plugin identity. |
20
20
  | `agent-control.mjs` | The recursive agent-control warning, and its remedy. |
21
+ | `review-cost.mjs` | The advisory review-cost verdict, shared account discovery, and path classification between dated validated snapshots. M4 and M9 carry its evidence. |
22
+ | `measure-review-cost.mjs` | Reproduces M9 across the open update population at live marketplace HEAD, with compare sources and explicit skipped reasons in JSON. |
21
23
  | `issue.mjs` | Renders the issue the way the form would, then has the marketplace's own parser judge it. |
22
24
  | `submit.mjs` | Assembles every check with its measured reason, and withholds the body when a blocking check fails. Three outcomes: `ready` (the body), `refused` (a blocking check failed) and `listed` (the plugin is already listed by its own repository: `identity.available` passes with the listing's record, the five body checks are omitted rather than drawn as waiting, no body exists on purpose, and `listing` carries the listed commit against the local one and the form to use for a newer commit). Decides the category and tags after the registry: a listed plugin, own or taken, is asked for neither; an unlisted one without them is asked through `ask.mjs` at a terminal, and is a usage error otherwise. Ends with `reproduce`, the command line that repeats the run without asking. Under `--offline` the validation-commit check is `skipped`, not passed: verdict `skipped`, listed under `skipped` and not `unknown`, never blocking, and the READY line says "1 check skipped (--offline)". |
23
25
  | `ask.mjs` | The two questions `submit` asks a person at a terminal, and only there: category and tags, numbered from the pinned form, with the marketplace's own presentation for the manifest's kinds (read from the pinned catalog builder) as the default where it is on the list. Prompts on stderr, nothing persisted. |
@@ -53,8 +55,27 @@ machine (docs/WEIGH.md):
53
55
  | `weigh/report.mjs` | The confirmation and the report for a person, drawn with `style.mjs`; the README sentence and the evidence path come last. |
54
56
  | `weigh/confirm.mjs` | The one question, at a terminal, on stderr. |
55
57
 
58
+ `tools/inspect/` is `omakit inspect`, what a plugin tree does as observations
59
+ (docs/INSPECT.md), regular expressions over QML and shell, read-only against
60
+ the tree, no verdict:
61
+
62
+ | File | Purpose |
63
+ | --- | --- |
64
+ | `inspect/inspect.mjs` | The command: resolve the subject the way `submit` does, walk the tree, run the four extractors, run `verify` for the baseline, evaluate the patterns, build the document. The fixed blind-spot list lives here. |
65
+ | `inspect/walk.mjs` | The installable tree at the commit (through `tree.mjs`) filtered to the kinds inspect reads: `.qml`, `.js`, `.mjs`, `.cjs`, the shell extensions, `.py`, and any blob with a shebang or the executable bit; the manifest for the id; prose never read for facts. |
66
+ | `inspect/text.mjs` | The text primitives every extractor shares: line numbers, brace blocks, a property's value inside a block, string and array literals, the crude shell word and segment split, command substitutions, redirections. |
67
+ | `inspect/processes.mjs` | Process sites: `Process {` blocks with their `command:` or `<id>.command =`, `execDetached` calls, and every command segment of a shell script; argv, deadline, collector, cap, shell wrapper. |
68
+ | `inspect/hosts.mjs` | Every `http` or `https` literal with its host, the tool it reaches, and the timeout and size-cap flags in the same argv; a host behind an expression is not resolvable, never guessed. |
69
+ | `inspect/writes.mjs` | Write sites in QML (`FileView` with a write), shell (redirects, `tee`, `cp`, `mv`, `mkdir`, `mktemp`, `install`, `touch`), JavaScript and Python, with the controlled-directory test over the canonical path prefix and the mode the file shows. |
70
+ | `inspect/timers.mjs` | `Timer {` blocks: interval, repeat, running, triggeredOnStart, and the handler outside the block that starts it. |
71
+ | `inspect/functions.mjs` | Function sites: `function name(` and multi-line handlers in QML and JavaScript, shell functions, Python defs, each with its length in lines, deepest nesting and branch count. |
72
+ | `inspect/patterns.mjs` | The ten review classes of M11 as data: id, label, precondition over the facts, measurement, share, and the phrase for the `not observed` line. `supply-chain` cites the baseline's own findings and detects nothing. Also the M12 size thresholds as data, and the longest-first order over them. |
73
+ | `inspect/contract.mjs` | The JSON contract of docs/INSPECT.md as a validator, run by the unit tests over every fixture document. |
74
+ | `inspect/report.mjs` | The report for a person, drawn with `style.mjs` only: `░ info` for a fact, `▒ ?` for one that could not be read, `▓ note` for a pattern row, `▔ skip` under `--offline`, and the closing word `INSPECTED`. |
75
+
56
76
  ```text
57
77
  omakit pin
78
+ omakit inspect /path/to/plugin-repo # what the tree does, as observations; --json for the document
58
79
  omakit submit /path/to/plugin-repo # asks for the category and tags at a terminal
59
80
  omakit submit /path/to/plugin-repo --category Widgets --tags bar,quickshell
60
81
  omakit submit https://github.com/owner/repo@<40-char sha> --category System --tags system
@@ -98,7 +119,9 @@ sequences and nothing else.
98
119
  built-in `fetch`, which does not read proxy environment variables by default.
99
120
  Behind a proxy, run them with `NODE_USE_ENV_PROXY=1`. `submit` reads two things
100
121
  online, the subject's default-branch HEAD and the marketplace's current
101
- registry, and `--offline` turns both off; `verify` needs no network at all
122
+ registry. A manual-review baseline also reads the account's open issue
123
+ discovery and issue bodies for batching advice; `--offline` turns these
124
+ reads off. `verify` needs no network at all
102
125
  beyond fetching a reviewer-mode subject, and `tests/parity/offline.mjs` proves
103
126
  it.
104
127
 
@@ -7,6 +7,7 @@
7
7
  // omakit verify <target> the official baseline over the local transport, verbatim
8
8
  // omakit parity [--count n] prove the local transport equals the GitHub transport
9
9
  // omakit weigh <plugin> | --all what a plugin weighs on the shell, measured by restarting it
10
+ // omakit inspect <plugin-dir> what a plugin tree does, as observations; decides nothing
10
11
  //
11
12
  // Nothing here writes to the marketplace. There is no POST, PATCH, PUT or
12
13
  // DELETE anywhere in this repository, and `tests/unit/read-only.test.mjs`
@@ -42,6 +43,8 @@ import { listWeighings } from "../weigh/list.mjs"
42
43
  import { askYes } from "../weigh/confirm.mjs"
43
44
  import { auditInstalled } from "../audit/audit.mjs"
44
45
  import { renderAudit } from "../audit/report.mjs"
46
+ import { inspectPlugin, NOT_READABLE } from "../inspect/inspect.mjs"
47
+ import { renderInspect } from "../inspect/report.mjs"
45
48
 
46
49
  const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..")
47
50
 
@@ -68,6 +71,16 @@ const REMEDY = Object.freeze({
68
71
  "interrupted": "shell.json was restored; run it again when the desktop is yours to restart.",
69
72
  })
70
73
 
74
+ /*
75
+ * A command that has written its result leaves through `process.exitCode`,
76
+ * never `process.exit()`: stdout is an API, and on a pipe whose reader has
77
+ * not started reading yet the exit cuts the output. Measured on 0.4.1:
78
+ * `omakit submit <listed plugin> --json | (sleep 2; cat)` delivered 8,192 of
79
+ * 14,033 bytes, and a parser downstream saw invalid JSON. The failure
80
+ * states below still exit at once: they write one short block to stderr,
81
+ * and their callers use them the way a throw is used.
82
+ */
83
+
71
84
  /**
72
85
  * Every failure, in one register, on stderr. `usage` errors carry the
73
86
  * signature that was expected, so the remedy is the reference and not a
@@ -90,9 +103,21 @@ function failFrom(error) {
90
103
  throw error
91
104
  }
92
105
 
106
+ /**
107
+ * The value of a valued option, written either way the table accepts,
108
+ * `--name value` or `--name=value`, the last occurrence winning as it does
109
+ * in options.mjs. Measured on 0.4.1: the table accepted `--out=FILE` and the
110
+ * value was looked up as the token after `--out`, so `doctor --out=x` wrote
111
+ * nothing and exited 0, and `submit --category=Widgets` said the flag was
112
+ * missing.
113
+ */
93
114
  function option(args, name) {
94
- const index = args.indexOf(name)
95
- return index >= 0 ? args[index + 1] : undefined
115
+ let value
116
+ for (let index = 0; index < args.length; index += 1) {
117
+ if (args[index] === name) value = args[index + 1]
118
+ else if (args[index].startsWith(`${name}=`)) value = args[index].slice(name.length + 1)
119
+ }
120
+ return value
96
121
  }
97
122
 
98
123
  /** The bare arguments, with every valued option's value (options.mjs, one table) left out. */
@@ -169,7 +194,8 @@ async function cmdSubmit(args) {
169
194
  const usage = error.usage
170
195
  if (json) {
171
196
  process.stdout.write(`${JSON.stringify({ usage }, null, 2)}\n`)
172
- process.exit(2)
197
+ process.exitCode = 2
198
+ return
173
199
  }
174
200
  const flags = usage.missing.join(" and ")
175
201
  fail("usage", `submit needs ${flags}: ${usage.missing.length === 1 ? "it is" : "they are"} an editorial choice nobody else can make, from the pinned form's own lists.`, 2,
@@ -185,7 +211,7 @@ async function cmdSubmit(args) {
185
211
  emit(args, args.includes("--json") ? `${JSON.stringify(result, null, 2)}\n` : reportText(args, renderSubmit, result))
186
212
  // Three outcomes, two exit codes: `ready` and `listed` are both healthy
187
213
  // states, and only a refusal is a 1.
188
- process.exit(result.outcome === "refused" ? 1 : 0)
214
+ process.exitCode = result.outcome === "refused" ? 1 : 0
189
215
  }
190
216
 
191
217
  async function cmdWatch(args) {
@@ -224,7 +250,7 @@ async function cmdWatch(args) {
224
250
  spinner.done()
225
251
  const render = result.mode === "list" ? renderWatchList : result.mode === "all" ? renderWatchAll : renderWatch
226
252
  emit(args, args.includes("--json") ? `${JSON.stringify(result, null, 2)}\n` : reportText(args, render, result))
227
- process.exit(result.verdict?.state === "unknown" || result.summary?.unknown > 0 ? 2 : 0)
253
+ process.exitCode = result.verdict?.state === "unknown" || result.summary?.unknown > 0 ? 2 : 0
228
254
  }
229
255
 
230
256
  async function cmdFrontDoor() {
@@ -243,15 +269,16 @@ async function cmdSetup(args) {
243
269
  if (args.includes("--completion")) {
244
270
  const identity = requirePin(ROOT).identity
245
271
  const result = await completionStep({ repoRoot: ROOT, pin: identity.commit, version: VERSION, askRc: false })
246
- process.exit(result.state === "ok" ? 0 : 1)
272
+ process.exitCode = result.state === "ok" ? 0 : 1
273
+ return
247
274
  }
248
275
  const result = await setup({ repoRoot: ROOT, entryPoint: resolve(ROOT, "bin/omakit"), yes: args.includes("--yes") })
249
- process.exit(result.ok ? 0 : 1)
276
+ process.exitCode = result.ok ? 0 : 1
250
277
  }
251
278
 
252
279
  async function cmdUpgrade(args) {
253
280
  const result = await upgrade({ repoRoot: ROOT, dryRun: args.includes("--dry-run") })
254
- process.exit(result.ok ? 0 : 1)
281
+ process.exitCode = result.ok ? 0 : 1
255
282
  }
256
283
 
257
284
  async function cmdDoctor(args) {
@@ -259,7 +286,7 @@ async function cmdDoctor(args) {
259
286
  const result = await doctor({ repoRoot: ROOT, offline: args.includes("--offline"), onPhase: spinner.phase })
260
287
  spinner.done()
261
288
  emit(args, args.includes("--json") ? `${JSON.stringify(result, null, 2)}\n` : reportText(args, renderDoctor, result))
262
- process.exit(result.problems ? 1 : 0)
289
+ process.exitCode = result.problems ? 1 : 0
263
290
  }
264
291
 
265
292
  async function cmdVerify(args) {
@@ -325,7 +352,7 @@ async function cmdParity(args) {
325
352
  } catch (error) {
326
353
  failFrom(error)
327
354
  }
328
- process.exit(ok ? 0 : 1)
355
+ process.exitCode = ok ? 0 : 1
329
356
  }
330
357
 
331
358
  function notAudited(message, remedy = null, exit = 1) {
@@ -359,7 +386,50 @@ async function cmdAudit(args) {
359
386
  }
360
387
  if (parsed.options.has("--json")) process.stdout.write(json)
361
388
  else process.stdout.write(`${renderAudit(document)}\n`)
362
- process.exit(document.ok ? 0 : 1)
389
+ process.exitCode = document.ok ? 0 : 1
390
+ }
391
+
392
+ /**
393
+ * `omakit inspect`: a report, exit 0 whatever it observed; exit 2 when the
394
+ * target could not be read (no directory, no Git checkout, no commit, no
395
+ * manifest), in the one failure register. There is no exit status for
396
+ * "found something", because finding something is the normal outcome.
397
+ * `--out` writes the document to a file beside whatever stdout gets, the
398
+ * way `audit --out` does.
399
+ */
400
+ async function cmdInspect(args) {
401
+ const parsed = checkArgs(args, ACCEPTED.inspect)
402
+ if (parsed.offending !== null) fail("usage", `${parsed.reason}. Accepted: ${acceptedWords("inspect")}.`, 2, "omakit inspect <plugin-dir> [--full] [--json] [--out FILE] [--offline] [--allow-dirty]")
403
+ const target = parsed.positionals[0]
404
+ if (!target) fail("usage", "inspect needs a plugin directory: `omakit inspect <plugin-dir>`", 2, "omakit inspect <plugin-dir> [--full] [--json] [--out FILE] [--offline] [--allow-dirty]")
405
+ const spinner = spinnerFor(args)
406
+ let document
407
+ try {
408
+ document = await inspectPlugin({
409
+ repoRoot: ROOT,
410
+ target,
411
+ offline: parsed.options.has("--offline"),
412
+ allowDirty: parsed.options.has("--allow-dirty"),
413
+ omakitVersion: VERSION,
414
+ onPhase: spinner.phase,
415
+ })
416
+ } catch (error) {
417
+ spinner.done()
418
+ if (error?.code && typeof error.code === "string") {
419
+ fail(error.code, error.message, NOT_READABLE.includes(error.code) ? 2 : 1, error.remedy || REMEDY[error.code])
420
+ }
421
+ throw error
422
+ }
423
+ spinner.done()
424
+ const json = `${JSON.stringify(document, null, 2)}\n`
425
+ const out = parsed.options.get("--out")
426
+ if (out) {
427
+ mkdirSync(dirname(resolve(out)), { recursive: true })
428
+ writeFileSync(resolve(out), json)
429
+ }
430
+ if (parsed.options.has("--json")) process.stdout.write(json)
431
+ else process.stdout.write(`${renderInspect(document, { full: parsed.options.has("--full") })}\n`)
432
+ process.exitCode = 0
363
433
  }
364
434
 
365
435
  /**
@@ -399,7 +469,7 @@ async function cmdWeigh(args) {
399
469
  throw error
400
470
  }
401
471
  process.stdout.write(json ? `${JSON.stringify(list.rows, null, 2)}\n` : `${renderList(list)}\n`)
402
- process.exit(0)
472
+ return
403
473
  }
404
474
  if (!target && !all) notWeighed("usage", "weigh needs a plugin: `omakit weigh <plugin-id-or-dir>`, or `omakit weigh --all` for every enabled third-party plugin.", "omakit weigh <plugin-id-or-dir>", 2)
405
475
  if (target && all) notWeighed("usage", `--all weighs every enabled third-party plugin, so ${JSON.stringify(target)} is one argument more than it takes.`, "omakit weigh --all, or omakit weigh <plugin-id-or-dir>", 2)
@@ -466,7 +536,6 @@ async function cmdWeigh(args) {
466
536
  } else {
467
537
  process.stdout.write(`\n${renderWeigh(document)}\n`)
468
538
  }
469
- process.exit(0)
470
539
  }
471
540
 
472
541
  const VERSION = JSON.parse(readFileSync(resolve(ROOT, "package.json"), "utf8")).version
@@ -539,6 +608,8 @@ if (command === "setup") {
539
608
  await cmdAudit(rest)
540
609
  } else if (command === "weigh") {
541
610
  await cmdWeigh(rest)
611
+ } else if (command === "inspect") {
612
+ await cmdInspect(rest)
542
613
  } else if (command === "help" || command === "--help" || command === "-h" || command === undefined) {
543
614
  if (rest.includes("--agent")) {
544
615
  // The skills ship in the npm package, so this works from a global install
@@ -180,10 +180,10 @@ export async function authenticatedUser() {
180
180
  }
181
181
 
182
182
  /** Repository issues by their creator. PRs are excluded; pagination never silently truncates. */
183
- export async function repositoryIssues(owner, repository, creator, { readJson = getJson, maxPages = 100 } = {}) {
183
+ export async function repositoryIssues(owner, repository, creator, { readJson = getJson, maxPages = 100, labels } = {}) {
184
184
  const all = []
185
185
  for (let page = 1; page <= maxPages; page += 1) {
186
- const query = new URLSearchParams({ creator, state: "open", sort: "updated", direction: "desc", per_page: "100", page: String(page) })
186
+ const query = new URLSearchParams({ ...(creator ? { creator } : {}), ...(labels ? { labels } : {}), state: "open", sort: "updated", direction: "desc", per_page: "100", page: String(page) })
187
187
  const batch = await readJson(`https://api.github.com/repos/${owner}/${repository}/issues?${query}`)
188
188
  if (!Array.isArray(batch)) throw new GitHubError("github-unavailable", "GitHub did not return an issue list")
189
189
  all.push(...batch.filter((item) => !item.pull_request))
@@ -205,6 +205,17 @@ export async function issueComments(owner, repository, number, maxPages = 10, re
205
205
  throw new GitHubError("comments-incomplete", `Issue #${number} exceeded ${maxPages} comment pages; its latest baseline cannot be determined`)
206
206
  }
207
207
 
208
+ /** Compare exact validated snapshots. The API caps its file list at 300: never classify a truncated diff. */
209
+ export async function compareCommits(repositoryUrl, previous, validated, { readJson = getJson } = {}) {
210
+ const match = String(repositoryUrl).match(/^https:\/\/github\.com\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?\/?$/)
211
+ if (!match || ![previous, validated].every((commit) => /^[a-f0-9]{40}$/i.test(commit))) throw new GitHubError("usage", "compare needs a repository and two full commit identifiers")
212
+ const url = `https://api.github.com/repos/${match[1]}/${match[2]}/compare/${previous}...${validated}?per_page=1`
213
+ const result = await readJson(url)
214
+ if (!["ahead", "identical"].includes(result?.status)) throw new GitHubError("compare-not-forward", "validated snapshots are not a forward comparison")
215
+ if (!Array.isArray(result.files) || result.files.length >= 300) throw new GitHubError("compare-incomplete", "compare file list unavailable or at the 300-file API limit")
216
+ return { url, files: result.files }
217
+ }
218
+
208
219
  /**
209
220
  * The current default-branch HEAD of a repository.
210
221
  *
@@ -92,10 +92,14 @@ function fileResponse(buffer, range) {
92
92
  }
93
93
 
94
94
  /**
95
- * @param {{ repoDir: string, repoUrl: string, commitSha: string, defaultBranch?: string }} options
95
+ * @param {{ repoDir: string, repoUrl: string, commitSha: string, defaultBranch?: string, subdir?: string }} options
96
+ * `subdir` names a directory below the repository root whose tree is served
97
+ * as the whole tree: the commit's `<sha>:<subdir>` tree, paths relative to
98
+ * it. `omakit inspect` uses it for a plugin kept below the root of a larger
99
+ * repository, so the baseline sees the plugin's tree and never the root's.
96
100
  * @returns {{ fetchImpl: Function, stats: { api: number, raw: number } }}
97
101
  */
98
- export function createLocalTransport({ repoDir, repoUrl, commitSha, defaultBranch }) {
102
+ export function createLocalTransport({ repoDir, repoUrl, commitSha, defaultBranch, subdir = "" }) {
99
103
  const { owner, repository } = parseRepoUrl(repoUrl)
100
104
  let commit = ""
101
105
  try {
@@ -106,8 +110,16 @@ export function createLocalTransport({ repoDir, repoUrl, commitSha, defaultBranc
106
110
  if (commit.toLowerCase() !== String(commitSha).toLowerCase()) {
107
111
  throw new Error(`local transport: ${repoDir} does not contain commit ${commitSha}`)
108
112
  }
109
- const treeSha = git(repoDir, ["rev-parse", `${commit}^{tree}`]).trim()
110
- const tree = readTree(repoDir, commit)
113
+ const root = subdir ? `${commit}:${subdir.replace(/\/+$/, "")}` : `${commit}^{tree}`
114
+ let treeSha = ""
115
+ try {
116
+ treeSha = git(repoDir, ["rev-parse", "--verify", "-q", root]).trim()
117
+ if (git(repoDir, ["cat-file", "-t", treeSha]).trim() !== "tree") treeSha = ""
118
+ } catch {
119
+ treeSha = ""
120
+ }
121
+ if (!treeSha) throw new Error(`local transport: ${repoDir} has no directory ${subdir} at commit ${commitSha}`)
122
+ const tree = readTree(repoDir, treeSha)
111
123
  const byPath = new Map(tree.map((entry) => [entry.path, entry]))
112
124
  const branch = defaultBranch
113
125
  || (() => {
@@ -0,0 +1,39 @@
1
+ // Reproduce M9 with GET-only issue discovery and compare reads. No sampling.
2
+ import { resolve } from "node:path"
3
+ import { pathToFileURL } from "node:url"
4
+ import { MARKETPLACE_PIN } from "./pin.mjs"
5
+ import { repositoryIssues } from "./github.mjs"
6
+ import { liveRegistry } from "./registry.mjs"
7
+ import { reviewPolicy } from "./review-cost.mjs"
8
+ import { validationWatchAll } from "./watch.mjs"
9
+
10
+ export async function measureReviewCost(repoRoot) {
11
+ const policy = await reviewPolicy(repoRoot)
12
+ const registry = await liveRegistry({ repoRoot })
13
+ if (registry.source !== "head") throw new Error(`cannot measure at HEAD: ${registry.reason}`)
14
+ const [owner, repository] = new URL(MARKETPLACE_PIN.repository).pathname.slice(1).split("/")
15
+ const openedAt = new Date().toISOString()
16
+ const subjects = await repositoryIssues(owner, repository, undefined, { labels: policy.updateLabel })
17
+ const issues = subjects.filter((subject) => subject.state === "open" && !subject.pull_request).map((subject) => ({
18
+ number: subject.number, url: `${MARKETPLACE_PIN.repository}/issues/${subject.number}`, title: subject.title,
19
+ state: subject.state, labels: subject.labels.map((label) => typeof label === "string" ? label : label.name),
20
+ }))
21
+ // Only the manual-review subset needs comment reads and comparisons. No plugin HEAD reads.
22
+ const manual = issues.filter((subject) => subject.labels.includes(policy.reviewLabel))
23
+ const batch = await validationWatchAll({ repoRoot, discovery: { account: null, marketplace: MARKETPLACE_PIN.repository, issues: manual },
24
+ readRegistry: async () => registry, github: { defaultBranchHead: async () => null } })
25
+ const counts = batch.reviewCostSummary
26
+ return {
27
+ measurement: "M9", openedAt, completedAt: new Date().toISOString(), marketplace: MARKETPLACE_PIN.repository,
28
+ marketplaceHead: registry.commit, command: "node tools/marketplace/measure-review-cost.mjs", sample: false,
29
+ pluginUpdates: issues.length, manualQueue: manual.length, manualQueueShare: issues.length ? manual.length / issues.length : null,
30
+ docsOnly: counts.docsOnly, compared: counts.compared, skipped: counts.skipped.length,
31
+ docsOnlyShareOfCompared: counts.compared ? counts.docsOnly / counts.compared : null,
32
+ docsOnlyShareOfManualQueue: counts.skipped.length || !manual.length ? null : counts.docsOnly / manual.length,
33
+ rows: batch.issues.map((row) => ({ issue: row.issue.number, ...row.documentationDiff })),
34
+ }
35
+ }
36
+
37
+ if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
38
+ process.stdout.write(`${JSON.stringify(await measureReviewCost(resolve(process.cwd())), null, 2)}\n`)
39
+ }
@@ -0,0 +1,76 @@
1
+ // M6 reproduction: open author-fixes issues, bot marker against commits.atom.
2
+ // All remote reads use the existing GET-only client. Unknowns stay unknown.
3
+ import { resolve } from "node:path"
4
+ import { pathToFileURL } from "node:url"
5
+ import { MARKETPLACE_PIN } from "./pin.mjs"
6
+ import { repositoryIssues, getText } from "./github.mjs"
7
+ import { validationWatch } from "./watch.mjs"
8
+
9
+ export function atomHead(feed, source) {
10
+ const commit = feed.match(/<id>tag:github\.com,2008:Grit::Commit\/([0-9a-f]{40})<\/id>/i)?.[1]
11
+ || feed.match(/\/commit\/([0-9a-f]{40})/i)?.[1]
12
+ if (!commit) throw new Error("No full HEAD commit in commits.atom")
13
+ return { source, commit: commit.toLowerCase(), branch: null,
14
+ committedAt: feed.match(/<updated>([^<]+)<\/updated>/)?.[1] || null }
15
+ }
16
+
17
+ export function stalenessCounts(rows) {
18
+ const stale = rows.filter((row) => row.verdict === "stale").length
19
+ const current = rows.filter((row) => row.verdict === "current").length
20
+ const compared = stale + current
21
+ return { total: rows.length, compared, stale, current, unknown: rows.length - compared,
22
+ staleShareOfCompared: compared ? stale / compared : null,
23
+ staleShareOfPopulation: compared === rows.length && rows.length ? stale / rows.length : null }
24
+ }
25
+
26
+ export async function measureStaleness(repoRoot, { discover = repositoryIssues, watch = validationWatch, readText = getText } = {}) {
27
+ const openedAt = new Date().toISOString()
28
+ const [owner, repository] = new URL(MARKETPLACE_PIN.repository).pathname.slice(1).split("/")
29
+ const batches = await Promise.all(["needs-fixes", "security-needs-fixes"].map((labels) => discover(owner, repository, undefined, { labels })))
30
+ const subjects = [...new Map(batches.flat().map((subject) => [subject.number, subject])).values()]
31
+ .filter((subject) => subject.state === "open" && !subject.pull_request)
32
+ .sort((a, b) => a.number - b.number)
33
+ const heads = new Map()
34
+ function readHead(url) {
35
+ if (!heads.has(url)) {
36
+ const source = `${url.replace(/\/$/, "")}/commits.atom`
37
+ heads.set(url, Promise.resolve().then(async () => atomHead(await readText(source, "application/atom+xml"), source)))
38
+ }
39
+ return heads.get(url)
40
+ }
41
+ const rows = new Array(subjects.length)
42
+ let next = 0
43
+ async function worker() {
44
+ while (next < subjects.length) {
45
+ const index = next++
46
+ const subject = subjects[index]
47
+ const issueUrl = `${MARKETPLACE_PIN.repository}/issues/${subject.number}`
48
+ const observedAt = new Date().toISOString()
49
+ try {
50
+ const report = await watch({ repoRoot, issueUrl, github: { issue: async () => subject, defaultBranchHead: readHead } })
51
+ rows[index] = { issue: subject.number, issueUrl, observedAt, completedAt: new Date().toISOString(),
52
+ repository: report.plugin.repository, validatedCommit: report.validated?.commit || null,
53
+ validationSource: report.validated?.source || null, validatedAt: report.validated?.checkedAt || null,
54
+ validationCommentsSource: `https://api.github.com/repos/${owner}/${repository}/issues/${subject.number}/comments`,
55
+ defaultBranchHead: report.head?.commit || null, headSource: report.head?.source || null,
56
+ verdict: report.verdict.state,
57
+ reason: report.verdict.state === "unknown" ? report.verdict.summary : null }
58
+ } catch (error) {
59
+ rows[index] = { issue: subject.number, issueUrl, observedAt, completedAt: new Date().toISOString(),
60
+ repository: null, validatedCommit: null, defaultBranchHead: null, verdict: "unknown",
61
+ reason: `${error.code || "read-unavailable"}: ${error.message}` }
62
+ }
63
+ }
64
+ }
65
+ await Promise.all(Array.from({ length: Math.min(4, subjects.length) }, worker))
66
+ return { measurement: "M6", date: openedAt.slice(0, 10), openedAt, completedAt: new Date().toISOString(),
67
+ marketplace: MARKETPLACE_PIN.repository, marketplacePin: MARKETPLACE_PIN.commit,
68
+ command: "node tools/marketplace/measure-staleness.mjs", sample: false,
69
+ population: "All open non-PR marketplace issues labelled needs-fixes or security-needs-fixes at discovery",
70
+ method: "Latest bot security-baseline full commit against default-branch commits.atom HEAD; missing full markers or feeds are unknown; unequal commits are stale, without a claim about ancestry",
71
+ ...stalenessCounts(rows), rows }
72
+ }
73
+
74
+ if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
75
+ process.stdout.write(`${JSON.stringify(await measureStaleness(resolve(process.cwd())), null, 2)}\n`)
76
+ }
@@ -28,6 +28,7 @@ export const ACCEPTED = Object.freeze({
28
28
  doctor: Object.freeze({ valued: ["--out"], flags: ["--offline", "--json"], positionals: 0 }),
29
29
  parity: Object.freeze({ valued: ["--count", "--offset", "--out"], flags: [], positionals: 0 }),
30
30
  audit: Object.freeze({ valued: ["--out"], flags: ["--drift", "--json", "--offline"], positionals: 1 }),
31
+ inspect: Object.freeze({ valued: ["--out"], flags: ["--full", "--json", "--offline", "--allow-dirty"], positionals: 1 }),
31
32
  weigh: Object.freeze({ valued: ["--runs", "--window", "--settle", "--out"], flags: ["--all", "--list", "--json", "--yes"], positionals: 1 }),
32
33
  })
33
34