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.
package/README.md CHANGED
@@ -1,126 +1,66 @@
1
1
  <p align="center">
2
- <img src="docs/media/banner.gif" alt="omakit" width="440">
2
+ <img src="https://raw.githubusercontent.com/mtolhuys/omakit/main/docs/media/banner.gif" alt="omakit" width="440">
3
3
  </p>
4
4
 
5
- The safe place to find out: everything knowable about an Omarchy Quattro plugin submission before you post it, on your own machine. Agent-first, read-only against the marketplace, posts nothing, zero dependencies.
5
+ The marketplace validates one exact commit of your plugin. Push a fix or comment "fixed", and nothing re-runs ([M6](docs/MEASUREMENTS.md#m6-the-validated-commit-falls-behind-silently-and-that-is-the-centre-of-this-tool)). omakit runs the marketplace's own checks locally, watches your submission and posts nothing.
6
6
 
7
7
  [![Built for Omarchy: App](https://raw.githubusercontent.com/tcballard/omarchy-badges/75975e5b5bf75e7ede3764bcd2950046f7abfe2c/badges/v1/omarchy-app.svg)](https://github.com/tcballard/omarchy-badges) [![npm version](https://img.shields.io/npm/v/omakit)](https://www.npmjs.com/package/omakit) [![CI status](https://img.shields.io/github/actions/workflow/status/mtolhuys/omakit/ci.yml?branch=main)](https://github.com/mtolhuys/omakit/actions/workflows/ci.yml) [![Socket](https://socket.dev/api/badge/npm/package/omakit)](https://socket.dev/npm/package/omakit)
8
8
 
9
- `omakit` is a zero-dependency Node CLI that checks an Omarchy Quattro plugin submission on your machine.
10
- It is for a coding agent or a person submitting a plugin.
11
- It never posts to the marketplace or writes into a plugin tree.
12
-
13
9
  ## Install
14
10
 
15
11
  ```bash
16
- npm install --global omakit
17
- omakit setup
12
+ npm i -g omakit && omakit setup
13
+ npx skills add mtolhuys/omakit
18
14
  ```
19
15
 
20
- See [docs/INSTALL.md](docs/INSTALL.md) for the clone route, PATH, requirements and upgrading.
16
+ Runs where [Node 22+](package.json) and Git run; `weigh` and `audit` need a running Omarchy shell.
21
17
 
22
- ## Commands
18
+ Licence: [MIT](LICENSE).
23
19
 
24
- | Command | What it does |
25
- | --- | --- |
26
- | [`omakit setup`](docs/COMMANDS.md) | The environment, the pin, tab completion, and what to try first. |
27
- | [`omakit submit <plugin-repo>`](docs/SUBMIT.md) | Every check, the issue title and body; asks for a category and tags at a terminal. |
28
- | [`omakit watch [<issue-url>]`](docs/VALIDATION_WATCH.md) | Pick your marketplace issues, list them, or check all with `--all`. |
29
- | [`omakit verify <plugin-repo>`](docs/COMMANDS.md) | The official security baseline over the local transport; `--json` for the document. |
30
- | [`omakit parity`](docs/COMMANDS.md) | The baseline over GitHub versus the local transport, on real listings; writes the evidence. |
31
- | [`omakit audit [<plugin>]`](docs/AUDIT.md) | Installed third-party commits against the exact commits the marketplace validated. |
32
- | [`omakit weigh <plugin>`](docs/WEIGH.md) | What a plugin weighs on the shell, measured by restarting it without and with the plugin; asks first. |
33
- | [`omakit doctor`](docs/COMMANDS.md) | What is installed, what is pinned, and what has moved. |
34
- | [`omakit pin`](docs/COMMANDS.md) | What setup does for the pin, on its own. |
35
- | [`omakit upgrade`](docs/COMMANDS.md) | Updates omakit through its own installer: npm, or a fast-forward. |
36
- | [`omakit help --agent`](docs/COMMANDS.md) | The operating instructions, for the agent running this. |
37
-
38
- Normal terminal use also checks for a newer npm release at most once daily
39
- and shows the upgrade command. It installs nothing automatically; scripts
40
- and JSON stay quiet. `DISABLE_UPDATE_NOTIFIER=1` disables the notice, and
41
- `omakit doctor` checks explicitly. [Update behaviour](docs/INSTALL.md#updating).
42
-
43
- Terminal help and reports adapt to the available width, up to 120 columns
44
- for readability. Narrow windows wrap sooner; pipes and text files keep the
45
- stable eighty-column layout. JSON and exact issue bodies remain intact.
46
-
47
- ### `submit`
20
+ ## `omakit submit <plugin-repo>`
48
21
 
49
- ```bash
50
- omakit submit <plugin-repo> --category Widgets --tags bar,quickshell
51
- ```
22
+ ![submit refusing a fixture plugin before any issue is posted](https://raw.githubusercontent.com/mtolhuys/omakit/main/docs/media/submit.gif)
52
23
 
53
- It decides whether the plugin is ready, refused, or already listed; [103 issues mention agent-control files that no automated check reports](docs/MEASUREMENTS.md).
24
+ Checks the exact commit with the marketplace's own baseline and, when ready, prints the exact issue title and body for you to paste. The GIF shows a refusal with three blocking checks and their fixes.
54
25
 
55
- ![omakit submit refusing a plugin with no license, a README that never says how to uninstall, and a reserved plugin id](docs/media/submit.gif)
26
+ ## `omakit watch --all`
56
27
 
57
- Read more: [docs/SUBMIT.md](docs/SUBMIT.md).
28
+ ![watch counts and two current issues, including human discussion](https://raw.githubusercontent.com/mtolhuys/omakit/main/docs/media/watch-all.gif)
58
29
 
59
- ### `watch`
30
+ Checks your submission commits and names the action that re-runs stale validation: edit the issue body. The GIF shows five CURRENT issues in the counts and the first two issues with a discussion; CURRENT means matching commits, not approval.
60
31
 
61
- ```bash
62
- omakit watch <submission-issue-url>
63
- omakit watch --all
64
- omakit watch --list
65
- omakit watch # choose one or several issues at a terminal
66
- ```
32
+ ## `omakit audit`
67
33
 
68
- It decides whether the marketplace validated the plugin's current commit; [73% of parked submissions have a HEAD the marketplace never saw](docs/MEASUREMENTS.md).
34
+ ![audit keeping drift rows and the DRIFT summary visible together](https://raw.githubusercontent.com/mtolhuys/omakit/main/docs/media/audit.gif)
69
35
 
70
- Account-wide discovery uses your signed-in `gh` account and reads your open marketplace issues. `--user <login>` reads another public account. Batch output includes baseline results, labels and the latest human discussion; `current` compares commits and does not imply approval or publication. Each command takes one snapshot and posts nothing.
36
+ Compares your installed plugin commits with the marketplace's validated commits. The GIF shows drift rows first: on the author's desktop, 9 of 18 audited plugins ran commits the marketplace never validated.
71
37
 
72
- ![omakit watch reporting that a validated commit has fallen behind](docs/media/watch.gif)
38
+ ## `omakit inspect <plugin-dir>`
73
39
 
74
- Read more: [docs/VALIDATION_WATCH.md](docs/VALIDATION_WATCH.md).
40
+ ![inspect showing a size score and the two review classes one fixture shows](https://raw.githubusercontent.com/mtolhuys/omakit/main/docs/media/inspect.gif)
75
41
 
76
- ### `weigh`
42
+ Reads a plugin's tree and prints what needs attention, biggest first: a size score (10 minus the mean rank of its functions among functions in listed plugins, [M12](docs/MEASUREMENTS.md#m12-how-long-a-plugins-functions-are-in-listed-trees)), the functions over what 90 of 100 listed functions stay under, then each review class the tree shows with the class's measured share of review findings ([M11](docs/MEASUREMENTS.md#m11-what-the-human-review-raises-by-class)) and up to five sites. No verdict, nothing run from the tree; `--full` is every site, `--json` the document. Over 18 listed plugins read at their validated commits, the extraction counted 515 process sites (57 QML `Process` blocks, 458 shell lines), 17 hosts, 63 writes and 40 timers, left 15 rows it could not resolve, and printed 73 pattern rows across 17 of the 18 ([record](docs/evidence/inspect/2026-09-15-listed-sample.json)).
77
43
 
78
- ```bash
79
- omakit weigh <plugin-id-or-dir>
80
- ```
81
-
82
- It measures what a plugin weighs on the shell, CPU and child processes, against a baseline taken the same minute; [in the lab, a 180 ms timer fixture measured 2.73% CPU above a 0.13% floor](docs/MEASUREMENTS.md).
83
-
84
- ```text
85
- Weighs no CPU above the floor (0.13%) and runs 2 child processes using 8.2 MB and 0.1% CPU, on Omarchy 4.0.0.alpha, measured with omakit weigh on 2026-09-14
86
- ```
44
+ ## `omakit weigh <plugin>`
87
45
 
88
- It restarts your shell and asks first. Memory is a shell fact; CPU and child processes are the weight.
46
+ ![completed three-run desktop weighing with baseline samples and the noise floor](https://raw.githubusercontent.com/mtolhuys/omakit/main/docs/media/weigh.gif)
89
47
 
90
- Read more: [docs/WEIGH.md](docs/WEIGH.md).
48
+ Measures the shell with and without your plugin, reading Pss and CPU. The GIF shows three completed runs on the author's desktop, with baseline and plugin samples and a 0.33% CPU floor ([method](docs/WEIGH.md)).
91
49
 
92
50
  ## Evidence, not claims
93
51
 
94
- | Claim | Proof |
52
+ | Measurement | Evidence |
95
53
  | --- | --- |
96
- | The local transport produces the marketplace's own result | 30 of 30 identical, [docs/evidence/parity/](docs/evidence/parity/) |
97
- | A local run touches no network | run inside `unshare -rn`, [docs/evidence/offline/](docs/evidence/offline/) |
98
- | The generated body is well formed | the marketplace's own parser, `tests/unit/issue.test.mjs` |
99
- | Nothing writes to the marketplace | `tests/unit/read-only.test.mjs`, over every source file |
100
- | No agent-control file can reach a plugin | `tests/unit/self-containment.test.mjs` |
101
- | `weigh` restores `shell.json` on every exit path, and runs a frozen list of Omarchy commands | `tests/unit/weigh.test.mjs` against a fake `/proc` and stub commands, `tests/unit/read-only.test.mjs` |
102
- | The GIFs above are real output | captures and renderer in [docs/media/](docs/media/) |
103
-
104
- Committed evidence records a digest of each side rather than the results themselves, because findings about a specific third-party plugin are not this project's to publish.
54
+ | Baseline parity | 30/30 identical results, [recorded corpus](docs/evidence/parity/2026-09-12-local-vs-github-2.json), 2026-09-12 |
55
+ | Stale validated commit | 326/519 readable comparisons stale (62.8%); 64/583 unknown, [2026-09-15 data](docs/evidence/staleness/2026-09-15.json) |
56
+ | Registry churn | 4,201/4,293 registry-only commits in 30 days, 2026-09-13, [M7](docs/MEASUREMENTS.md#m7-the-registry-moves-by-the-hour-the-code-and-the-rules-move-by-the-week) |
57
+ | GIFs are recorded output | 6 GIFs with [captures and scenes](docs/media/README.md) |
58
+ | Posts nothing | 0 marketplace writes, [M10](docs/MEASUREMENTS.md#m10-readme-evidence-and-command-captures) |
59
+ | Zero dependencies | 0 runtime and 0 development dependencies, counted in [package.json](package.json) |
105
60
 
106
61
  ## Documentation
107
62
 
108
- | Document | For |
109
- | --- | --- |
110
- | [docs/INSTALL.md](docs/INSTALL.md) | install details, PATH, requirements, upgrading, and what Socket reports and why |
111
- | [docs/HOW.md](docs/HOW.md) | what omakit is doing, why it uses Node, the baseline and check labels |
112
- | [docs/COMMANDS.md](docs/COMMANDS.md) | command details, authentication and network behaviour |
113
- | [docs/AUDIT.md](docs/AUDIT.md) | installed plugin drift against marketplace-validated commits, with JSON origins |
114
- | [docs/SUBMIT.md](docs/SUBMIT.md) | every check and what it decides |
115
- | [docs/WEIGH.md](docs/WEIGH.md) | what `weigh` measures, the noise floor, the `shell.json` mutation and its restore, and the JSON contract |
116
- | [docs/VALIDATION_WATCH.md](docs/VALIDATION_WATCH.md) | the validation watch: what the marketplace validated, and what moves it |
117
- | [docs/MEASUREMENTS.md](docs/MEASUREMENTS.md) | every number, its method and its limits |
118
- | [docs/UPSTREAM_CONTRACT.md](docs/UPSTREAM_CONTRACT.md) | the seam, the pin, the boundaries |
119
- | [docs/MARKETPLACE.md](docs/MARKETPLACE.md) | who this actually helps |
120
- | [docs/PALETTE.md](docs/PALETTE.md) | every installed Omarchy theme measured, and which palette index each role gets |
121
- | [docs/TUI.md](docs/TUI.md) | what the terminal shows, and why it looks that way |
122
- | [AGENTS.md](AGENTS.md) | changing this repository |
123
-
124
- MIT. Derived work built on public data from
125
- `omacom/omarchy-plugin-marketplace`; not affiliated with or endorsed by that
126
- project.
63
+ - Using: [install](docs/INSTALL.md), [commands](docs/COMMANDS.md), [audience](docs/MARKETPLACE.md).
64
+ - Checks and measurements: [submit](docs/SUBMIT.md), [inspect](docs/INSPECT.md), [watch](docs/VALIDATION_WATCH.md), [audit](docs/AUDIT.md), [evidence](docs/MEASUREMENTS.md).
65
+ - Method docs: [how](docs/HOW.md), [weigh](docs/WEIGH.md), [upstream contract](docs/UPSTREAM_CONTRACT.md), [palette](docs/PALETTE.md), [terminal](docs/TUI.md).
66
+ - Contributing: [repository rules](AGENTS.md), [releasing](docs/RELEASING.md), [media](docs/media/README.md).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omakit",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "description": "The safe place to find out: everything knowable about an Omarchy Quattro plugin submission before you post it, on your own machine. Agent-first, read-only against the marketplace, posts nothing, zero dependencies.",
5
5
  "license": "MIT",
6
6
  "author": "Maarten Tolhuijs",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: omarchy-plugin-check
3
- description: Check an Omarchy Quattro plugin while building or changing it, before committing or pushing. Use whenever you create, edit, refactor or test an Omarchy plugin, when asked whether a plugin is marketplace-ready, or before any push to its default branch. Runs the marketplace's own security baseline and submission checks locally, read-only, and reports what the marketplace would refuse.
3
+ description: Check an Omarchy Quattro plugin while building or changing it, before committing or pushing. Use whenever you create, edit, refactor or test an Omarchy plugin, when asked whether a plugin is marketplace-ready, or before any push to its default branch. Runs the marketplace's own security baseline and submission checks locally, read-only, reports what the marketplace would refuse, and lists what the tree does (processes, hosts, writes, timers) as observations beside the review classes a human reviewer raises most.
4
4
  ---
5
5
 
6
6
  # Checking a plugin while you build it
@@ -31,6 +31,62 @@ editorial choice about where the plugin belongs.
31
31
  needs the network and reads the listed ids from the pin. For the real
32
32
  submission, drop it, and use `skills/omarchy-plugin-submit/SKILL.md`.
33
33
 
34
+ ## Read what the tree does before a human does
35
+
36
+ After the two commands above pass, and again whenever you add or change a
37
+ `Process`, a `curl`, a `FileView`, a shell script or a `Timer`, run:
38
+
39
+ ```bash
40
+ omakit inspect <path-to-the-plugin-repo> --json
41
+ ```
42
+
43
+ `inspect` lists what the tree shows, in the order a reviewer reads it: every
44
+ process with its argv and whether a deadline is observed for it, every host
45
+ with its timeout and size-cap flags, every write with whether its path falls
46
+ under a directory the plugin controls, every timer with its interval, and the
47
+ baseline's capabilities. Below the facts, `patterns` holds one entry per
48
+ review class the marketplace's human review has raised, only where the tree
49
+ shows the class's precondition (a process with no deadline, a collector with
50
+ no cap, a write under `/tmp`, `curl` without `-q`), with the class's measured
51
+ share of review findings (`share`, from M11 of `docs/MEASUREMENTS.md`).
52
+ `lookedFor` names the classes whose precondition was not observed.
53
+ `size.over` lists the functions longer, more branched or deeper than 90 of
54
+ 100 functions in listed trees (the thresholds are in `size.thresholds`,
55
+ from M12), longest first, each with its `percentile` among them, and
56
+ `size.score` is 10 minus the mean percentile of every function in the
57
+ tree. When the owner asks for simpler code, start with the top of
58
+ `size.over`, and read the score before and after as the measure of the
59
+ change; it is a position among listed plugins, not a grade, so never tell
60
+ the owner a score is good or bad, only that it moved. Read
61
+ the document, not the report: the report a person sees lists at most five
62
+ sites per class and drops classes under five percent, `--full` prints every
63
+ site, and `--json` carries all of it either way.
64
+
65
+ How to read "observed". Every row is what regular expressions found in the
66
+ text at `file:line`, never a runtime fact and never a verdict: `deadline
67
+ observed` means a killing `Timer`, a `timeout` in argv or a destruction
68
+ handler is in the file; `no cap observed` means the argv shows none of
69
+ `head -c`, `--max-filesize` or `timeout`; `observed nothing of this kind`
70
+ means the extraction found nothing, not that the tree is clean. A `▒ ?` row
71
+ (`argvForm: "computed"`, `argv: null`) is a command the text does not show
72
+ as a literal; do not guess it for the owner, read the file. `notVisible`
73
+ names what the method cannot see (commands built at run time, values from
74
+ variables or config, components outside the tree), and a tree that uses
75
+ those has facts `inspect` did not list.
76
+
77
+ What to do with a pattern row. It is not a marketplace rule and not a
78
+ finding: the marketplace's automated baseline blocks, a maintainer reviews,
79
+ and `inspect` only reports that the tree shows something reviewers have
80
+ raised in about N of every 100 findings. Show the owner the row and the
81
+ requirement in the reviewer's own terms from the M11 table (an absolute
82
+ deadline, producer-side bounds, a private 0700 directory, `curl -q`, no
83
+ secret in argv), and let the owner decide; never describe the row as a
84
+ requirement the marketplace enforces, and never say "safe" or "clean" about
85
+ a tree with no rows. The `supply-chain` row is the baseline's own finding
86
+ restated with its evidence sites; the baseline result is what decides there.
87
+ Exit status is 0 whenever a report was produced, so do not read the exit
88
+ code as pass or fail; 2 means the target could not be read.
89
+
34
90
  ## If omakit is not installed
35
91
 
36
92
  ```bash
@@ -92,8 +148,8 @@ own words, and so should you.
92
148
 
93
149
  The marketplace validates the pushed default-branch HEAD, not whatever is
94
150
  checked out locally. Run the check on the commit that will be pushed, then
95
- commit and push before the real submission. Of the 464 submissions parked in
96
- their author's court, 73% have a HEAD the marketplace never saw; that is the
151
+ commit and push before the real submission. On 2026-09-15, 326/519 readable author-fixes
152
+ comparisons were stale, with 64 of 583 issues unknown; that is the
97
153
  round this loop is meant to prevent.
98
154
 
99
155
  ## When the plugin is ready
@@ -133,8 +133,8 @@ strip files from their tree on your own.
133
133
 
134
134
  **`submission.validation-commit`.** The marketplace validates the commit it
135
135
  resolves when the issue is opened or edited, which is the pushed default-branch
136
- HEAD, not whatever is checked out locally. Push first, then submit. 73% of submissions
137
- parked in their author's court have a HEAD the marketplace never saw.
136
+ HEAD, not whatever is checked out locally. Push first, then submit. On 2026-09-15, 326/519 readable author-fixes comparisons were stale
137
+ (62.8%), with 64 of 583 issues unknown.
138
138
 
139
139
  ## What the baseline result means
140
140
 
@@ -18,10 +18,10 @@ the issue body**.
18
18
  submissions.
19
19
 
20
20
  So pushing a fix does nothing, and commenting "fixed in `abc123`" does nothing.
21
- Both feel like progress. Neither is. This is the single most common reason a
22
- submission sits still: of the 464 submissions parked in their author's court, 73%
23
- have a default-branch HEAD the marketplace never saw, and 82% of the authors whose
24
- push came after a review comment had also commented: engaged, and stuck.
21
+ Both feel like progress. Neither is. The full author-fixes queue was measured on 2026-09-15: 326/519 readable
22
+ comparisons were stale (62.8%), with 64 of 583 issues unknown. The older
23
+ 2026-09-12 sample found 68/93 readable issues stale (73.1%); that rate was
24
+ not a measurement of all 464 issues in the queue.
25
25
 
26
26
  ## Check it
27
27
 
@@ -0,0 +1,245 @@
1
+ // The JSON contract of docs/INSPECT.md, executable. `validateInspectDocument()`
2
+ // returns every way a document departs from it, as sentences, and an empty
3
+ // list when it does not. tests/unit/inspect.test.mjs holds every produced
4
+ // document to it, so the prose and the code cannot drift apart unnoticed.
5
+ //
6
+ // Run as a program: `node tools/inspect/contract.mjs <document.json>` prints
7
+ // the problems and exits 1 on any.
8
+
9
+ import { readFileSync } from "node:fs"
10
+ import { resolve } from "node:path"
11
+ import { pathToFileURL } from "node:url"
12
+
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"])
16
+ const CONTROLLED = new Set(["observed", "not-observed", "unknown"])
17
+ const DECLARED_IN = new Set(["qml", "shell"])
18
+ const FILE_KINDS = ["qml", "js", "shell", "python", "other"]
19
+
20
+ const isString = (value) => typeof value === "string"
21
+ const isBool = (value) => typeof value === "boolean"
22
+ const isInt = (value) => Number.isInteger(value)
23
+ const nullOr = (test) => (value) => value === null || test(value)
24
+
25
+ function site(value, at, problems) {
26
+ if (!value || typeof value !== "object") {
27
+ problems.push(`${at} is not a site`)
28
+ return
29
+ }
30
+ if (!isString(value.file)) problems.push(`${at}.file is not a string`)
31
+ if (!isInt(value.line) || value.line < 1) problems.push(`${at}.line is not a positive integer`)
32
+ }
33
+
34
+ function processRow(row, at, problems) {
35
+ site(row, at, problems)
36
+ if (!DECLARED_IN.has(row.declaredIn)) problems.push(`${at}.declaredIn is neither qml nor shell`)
37
+ if (!nullOr(isString)(row.id)) problems.push(`${at}.id is neither a string nor null`)
38
+ if (!ARGV_FORMS.has(row.argvForm)) problems.push(`${at}.argvForm is not array, string or computed`)
39
+ if (row.argvForm === "computed") {
40
+ if (row.argv !== null) problems.push(`${at}.argv is not null for a computed command`)
41
+ if (!isString(row.commandText)) problems.push(`${at}.commandText is not the expression text of a computed command`)
42
+ } else if (!Array.isArray(row.argv) || row.argv.some((word) => !isString(word))) problems.push(`${at}.argv is not a list of strings`)
43
+ if (!Array.isArray(row.expressions) || row.expressions.some((entry) => !isInt(entry?.index) || !isString(entry?.text))) problems.push(`${at}.expressions is not a list of { index, text }`)
44
+ if (row.argvForm === "array" && Array.isArray(row.argv)) {
45
+ 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
+ for (const key of ["running", "detached", "shellWrapper"]) if (!isBool(row[key])) problems.push(`${at}.${key} is not a boolean`)
48
+ const deadline = row.deadline || {}
49
+ 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`)
51
+ if (deadline.observed !== (deadline.via !== null)) problems.push(`${at}.deadline.observed disagrees with deadline.via`)
52
+ if (!nullOr(isInt)(deadline.ms)) problems.push(`${at}.deadline.ms is neither an integer nor null`)
53
+ const output = row.output || {}
54
+ if (!COLLECTORS.has(output.collector)) problems.push(`${at}.output.collector is not StdioCollector, SplitParser, none or unknown`)
55
+ if (!isBool(output.capObserved)) problems.push(`${at}.output.capObserved is not a boolean`)
56
+ if (!nullOr(isString)(output.via)) problems.push(`${at}.output.via is neither a string nor null`)
57
+ if (output.capObserved !== (output.via !== null)) problems.push(`${at}.output.capObserved disagrees with output.via`)
58
+ if (row.pipedFrom !== null && (!isInt(row.pipedFrom?.line) || !isString(row.pipedFrom?.argv0))) problems.push(`${at}.pipedFrom is neither null nor { line, argv0 }`)
59
+ }
60
+
61
+ function host(row, at, problems) {
62
+ site(row, at, problems)
63
+ if (!isString(row.host) || !row.host) problems.push(`${at}.host is not a host name`)
64
+ if (row.scheme !== "http" && row.scheme !== "https") problems.push(`${at}.scheme is neither http nor https`)
65
+ if (!nullOr(isString)(row.tool)) problems.push(`${at}.tool is neither a string nor null`)
66
+ for (const key of ["timeout", "sizeCap"]) {
67
+ const flag = row[key] || {}
68
+ if (!isBool(flag.observed)) problems.push(`${at}.${key}.observed is not a boolean`)
69
+ if (!nullOr(isString)(flag.via)) problems.push(`${at}.${key}.via is neither a string nor null`)
70
+ if (flag.observed !== (flag.via !== null)) problems.push(`${at}.${key}.observed disagrees with ${key}.via`)
71
+ }
72
+ if (!Array.isArray(row.flags) || row.flags.some((flag) => !isString(flag))) problems.push(`${at}.flags is not a list of strings`)
73
+ if (!isBool(row.privateAddress)) problems.push(`${at}.privateAddress is not a boolean`)
74
+ }
75
+
76
+ function write(row, at, problems) {
77
+ site(row, at, problems)
78
+ if (!isString(row.path)) problems.push(`${at}.path is not a string`)
79
+ if (!nullOr(isString)(row.canonicalPath)) problems.push(`${at}.canonicalPath is neither a string nor null`)
80
+ if (!isString(row.via)) problems.push(`${at}.via is not a string`)
81
+ if (!CONTROLLED.has(row.controlledDirectory)) problems.push(`${at}.controlledDirectory is not observed, not-observed or unknown`)
82
+ if ((row.controlledDirectory === "observed") !== isString(row.controlledBy)) problems.push(`${at}.controlledBy names a directory exactly when controlledDirectory is observed`)
83
+ if (row.canonicalPath === null && row.controlledDirectory !== "unknown") problems.push(`${at}.controlledDirectory is decided for a path that could not be read`)
84
+ if (!isBool(row.temp)) problems.push(`${at}.temp is not a boolean`)
85
+ if (!nullOr(isString)(row.mode)) problems.push(`${at}.mode is neither a string nor null`)
86
+ }
87
+
88
+ function fn(row, at, problems) {
89
+ site(row, at, problems)
90
+ if (!isString(row.name) || !row.name) problems.push(`${at}.name is not a name`)
91
+ if (row.kind !== "function" && row.kind !== "handler") problems.push(`${at}.kind is neither function nor handler`)
92
+ for (const key of ["lines", "depth", "branches"]) if (!isInt(row[key]) || row[key] < 0) problems.push(`${at}.${key} is not a count`)
93
+ if (isInt(row.lines) && row.lines < 1) problems.push(`${at}.lines is under one`)
94
+ if (typeof row.percentile !== "number" || row.percentile < 0 || row.percentile > 100) problems.push(`${at}.percentile is not a rank between 0 and 100`)
95
+ }
96
+
97
+ function timer(row, at, problems) {
98
+ site(row, at, problems)
99
+ if (!nullOr(isString)(row.id)) problems.push(`${at}.id is neither a string nor null`)
100
+ if (!nullOr(isInt)(row.intervalMs)) problems.push(`${at}.intervalMs is neither an integer nor null`)
101
+ if (!nullOr(isString)(row.intervalText)) problems.push(`${at}.intervalText is neither a string nor null`)
102
+ if (row.intervalMs !== null && row.intervalText !== null) problems.push(`${at} has both an interval and an interval expression`)
103
+ for (const key of ["repeat", "running", "triggeredOnStart"]) if (!nullOr(isBool)(row[key])) problems.push(`${at}.${key} is neither a boolean nor null`)
104
+ if (!nullOr(isString)(row.startedBy)) problems.push(`${at}.startedBy is neither a string nor null`)
105
+ }
106
+
107
+ /**
108
+ * @param {object} document
109
+ * @param {{ patternIds?: string[], notVisible?: string[] }} [known] the ids
110
+ * patterns.mjs defines and the fixed blind-spot list, when the caller has
111
+ * them, so a pattern id the code does not define and a rewritten blind
112
+ * spot are both problems.
113
+ * @returns {string[]} problems; empty when the document follows the contract
114
+ */
115
+ export function validateInspectDocument(document, known = {}) {
116
+ const problems = []
117
+ if (!document || typeof document !== "object") return ["the document is not an object"]
118
+ if (!isString(document.omakit)) problems.push("omakit is not a string")
119
+ if (document.command !== "inspect") problems.push('command is not "inspect"')
120
+ if (!isString(document.method) || !/observed/.test(document.method)) problems.push("method is not the one sentence that says observed")
121
+ const subject = document.subject || {}
122
+ if (!isString(subject.dir)) problems.push("subject.dir is not a string")
123
+ if (!nullOr((value) => /^[0-9a-f]{40}$/.test(value))(subject.commit)) problems.push("subject.commit is neither a 40-character sha nor null")
124
+ if (!subject.repository || !nullOr(isString)(subject.repository.url)) problems.push("subject.repository.url is neither a string nor null")
125
+ const filesRead = subject.filesRead || {}
126
+ for (const kind of FILE_KINDS) if (!isInt(filesRead[kind]) || filesRead[kind] < 0) problems.push(`subject.filesRead.${kind} is not a count`)
127
+ for (const key of Object.keys(filesRead)) if (!FILE_KINDS.includes(key)) problems.push(`subject.filesRead.${key} is not a kind inspect reads`)
128
+
129
+ const observed = document.observed || {}
130
+ for (const [key, check] of [["processes", processRow], ["hosts", host], ["writes", write], ["timers", timer], ["functions", fn]]) {
131
+ if (!Array.isArray(observed[key])) {
132
+ problems.push(`observed.${key} is not a list`)
133
+ continue
134
+ }
135
+ for (const [index, row] of observed[key].entries()) check(row, `observed.${key}[${index}]`, problems)
136
+ }
137
+ const counts = document.counts || {}
138
+ if (!counts.processes || typeof counts.processes !== "object") problems.push("counts.processes is not the qml and shell split")
139
+ else {
140
+ for (const key of ["total", "qml", "shell"]) if (!isInt(counts.processes[key]) || counts.processes[key] < 0) problems.push(`counts.processes.${key} is not a count`)
141
+ if (counts.processes.total !== counts.processes.qml + counts.processes.shell) problems.push("counts.processes.total is not qml plus shell")
142
+ if (Array.isArray(observed.processes)) {
143
+ if (counts.processes.total !== observed.processes.length) problems.push("counts.processes.total is not the number of process rows")
144
+ if (counts.processes.qml !== observed.processes.filter((row) => row.declaredIn === "qml").length) problems.push("counts.processes.qml is not the number of qml process rows")
145
+ }
146
+ }
147
+ for (const key of ["hosts", "writes", "timers", "functions"]) {
148
+ if (!isInt(counts[key]) || counts[key] < 0) problems.push(`counts.${key} is not a count`)
149
+ else if (Array.isArray(observed[key]) && counts[key] !== observed[key].length) problems.push(`counts.${key} is not the number of ${key} rows`)
150
+ }
151
+ if (!isInt(counts.notResolvable) || (Array.isArray(document.notResolvable) && counts.notResolvable !== document.notResolvable.length)) problems.push("counts.notResolvable is not the number of notResolvable rows")
152
+ if (!Array.isArray(document.notResolvable)) problems.push("notResolvable is not a list")
153
+ else for (const [index, row] of document.notResolvable.entries()) {
154
+ site(row, `notResolvable[${index}]`, problems)
155
+ if (!isString(row.kind) || !isString(row.text)) problems.push(`notResolvable[${index}] lacks kind and text`)
156
+ }
157
+ // Every computed command is also a site the extraction could not read.
158
+ for (const row of Array.isArray(observed.processes) ? observed.processes : []) {
159
+ if (row.argvForm === "computed" && !(document.notResolvable || []).some((entry) => entry.kind === "command" && entry.file === row.file && entry.line === row.line)) {
160
+ problems.push(`observed.processes at ${row.file}:${row.line} is computed but not listed under notResolvable`)
161
+ }
162
+ }
163
+
164
+ const size = document.size
165
+ if (!size || typeof size !== "object") problems.push("size is missing")
166
+ else {
167
+ if (!isString(size.measurement) || !/^M\d+$/.test(size.measurement)) problems.push("size.measurement is not a measurement id")
168
+ 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`)
169
+ if (!size.sample || !isInt(size.sample.trees) || !isInt(size.sample.functions)) problems.push("size.sample is not { trees, functions }")
170
+ const scorable = Array.isArray(observed.functions) && observed.functions.length > 0
171
+ if (size.score === null) {
172
+ if (scorable) problems.push("size.score is null for a tree with functions")
173
+ } else if (typeof size.score !== "number" || size.score < 0 || size.score > 10 || Math.abs(Math.round(size.score * 100) - size.score * 100) > 1e-6) problems.push("size.score is not a number from 0 to 10 with two decimals")
174
+ else if (!scorable) problems.push("size.score is set for a tree with no function")
175
+ else {
176
+ const expected = Math.round((10 - observed.functions.reduce((sum, row) => sum + row.percentile, 0) / observed.functions.length / 10) * 100) / 100
177
+ if (Math.abs(expected - size.score) > 0.011) problems.push(`size.score is ${size.score}; the mean rank of the functions says ${expected}`)
178
+ }
179
+ if (!Array.isArray(size.over)) problems.push("size.over is not a list")
180
+ else {
181
+ for (const [index, row] of size.over.entries()) {
182
+ fn(row, `size.over[${index}]`, problems)
183
+ if (size.thresholds && !(row.lines > size.thresholds.lines || row.branches > size.thresholds.branches || row.depth > size.thresholds.depth)) problems.push(`size.over[${index}] is under every threshold`)
184
+ if (index && size.over[index - 1].lines < row.lines) problems.push(`size.over[${index}] is longer than the one before it; the list is longest first`)
185
+ }
186
+ if (Array.isArray(observed.functions)) {
187
+ const listed = new Set(size.over.map((row) => `${row.file}:${row.line}`))
188
+ for (const row of observed.functions) {
189
+ const over = row.lines > size.thresholds?.lines || row.branches > size.thresholds?.branches || row.depth > size.thresholds?.depth
190
+ if (over && !listed.has(`${row.file}:${row.line}`)) problems.push(`observed.functions at ${row.file}:${row.line} is over a threshold but not under size.over`)
191
+ }
192
+ }
193
+ }
194
+ }
195
+ if (!Array.isArray(document.patterns)) problems.push("patterns is not a list")
196
+ else for (const [index, row] of document.patterns.entries()) {
197
+ const at = `patterns[${index}]`
198
+ if (!isString(row.id)) problems.push(`${at}.id is not a string`)
199
+ else if (known.patternIds && !known.patternIds.includes(row.id)) problems.push(`${at}.id ${row.id} is not a pattern patterns.mjs defines`)
200
+ if (!isInt(row.observedCount) || row.observedCount < 1) problems.push(`${at}.observedCount is not a positive count: a pattern is listed only where its precondition was observed`)
201
+ if (!Array.isArray(row.sites) || row.sites.length === 0) problems.push(`${at}.sites is not a non-empty list`)
202
+ else for (const [n, entry] of row.sites.entries()) site(entry, `${at}.sites[${n}]`, problems)
203
+ if (!isString(row.measurement) || !/^M\d+$/.test(row.measurement)) problems.push(`${at}.measurement is not a measurement id`)
204
+ if (typeof row.share !== "number" || !(row.share > 0 && row.share < 1)) problems.push(`${at}.share is not a share between 0 and 1`)
205
+ if (!isString(row.observation) || !/^observed /.test(row.observation)) problems.push(`${at}.observation does not start with the word observed`)
206
+ if (!isString(row.summary) || !row.summary || /\w:\d+/.test(row.summary)) problems.push(`${at}.summary is not the observation without its sites`)
207
+ if (/\b(?:missing|should|fix)\b/i.test(String(row.observation))) problems.push(`${at}.observation reads as a verdict`)
208
+ }
209
+ if (!Array.isArray(document.lookedFor) || document.lookedFor.some((id) => !isString(id))) problems.push("lookedFor is not a list of pattern ids")
210
+ else {
211
+ const listed = new Set((document.patterns || []).map((row) => row.id))
212
+ for (const id of document.lookedFor) {
213
+ if (listed.has(id)) problems.push(`lookedFor names ${id}, which is also under patterns`)
214
+ if (known.patternIds && !known.patternIds.includes(id)) problems.push(`lookedFor names ${id}, which patterns.mjs does not define`)
215
+ }
216
+ if (known.patternIds) {
217
+ for (const id of known.patternIds) if (!listed.has(id) && !document.lookedFor.includes(id)) problems.push(`pattern ${id} is neither under patterns nor under lookedFor`)
218
+ }
219
+ }
220
+ if (!Array.isArray(document.notVisible) || document.notVisible.some((line) => !isString(line))) problems.push("notVisible is not a list of sentences")
221
+ else if (known.notVisible && JSON.stringify(document.notVisible) !== JSON.stringify(known.notVisible)) problems.push("notVisible is not the fixed blind-spot list, verbatim")
222
+
223
+ const baseline = document.marketplaceBaseline
224
+ if (!baseline || typeof baseline !== "object") problems.push("marketplaceBaseline is missing")
225
+ else if (baseline.skipped === true) {
226
+ if (!isString(baseline.reason)) problems.push("marketplaceBaseline.reason is not a string")
227
+ } else {
228
+ if (!baseline.pin || !isString(baseline.pin.commit)) problems.push("marketplaceBaseline.pin.commit is not a string")
229
+ if (!isString(baseline.transport)) problems.push("marketplaceBaseline.transport is not a string")
230
+ if (!isBool(baseline.invoked)) problems.push("marketplaceBaseline.invoked is not a boolean")
231
+ if (!isString(baseline.statement)) problems.push("marketplaceBaseline.statement is not a string")
232
+ if (baseline.invoked && (!baseline.official || typeof baseline.official !== "object")) problems.push("marketplaceBaseline.official is missing for an invoked baseline")
233
+ }
234
+ return problems
235
+ }
236
+
237
+ const invoked = process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href
238
+ if (invoked) {
239
+ const input = process.argv[2]
240
+ if (!input) throw new Error("usage: node tools/inspect/contract.mjs <document.json>")
241
+ const problems = validateInspectDocument(JSON.parse(readFileSync(resolve(input), "utf8")))
242
+ for (const problem of problems) process.stdout.write(`${problem}\n`)
243
+ process.stdout.write(problems.length ? `${problems.length} problem(s)\n` : "ok: the document follows the contract in docs/INSPECT.md\n")
244
+ process.exit(problems.length ? 1 : 0)
245
+ }