omakit 0.1.7 → 0.1.9

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
@@ -2,7 +2,7 @@
2
2
  <img src="docs/media/banner.gif" alt="omakit" width="440">
3
3
  </p>
4
4
 
5
- [![Built for Omarchy](https://raw.githubusercontent.com/tcballard/omarchy-badges/85f859029e236e784e7b05ada6dbe73506d07a91/badges/v1/built-for-omarchy.svg)](https://github.com/tcballard/omarchy-badges)
5
+ [![Built for Omarchy: App](https://raw.githubusercontent.com/tcballard/omarchy-badges/75975e5b5bf75e7ede3764bcd2950046f7abfe2c/badges/v1/omarchy-app.svg)](https://github.com/tcballard/omarchy-badges)
6
6
 
7
7
  **Everything knowable about an Omarchy Quattro plugin submission, checked
8
8
  before you post it:** the tree, the manifest, the form, the commit, and the
@@ -56,7 +56,7 @@ omakit setup
56
56
  ```
57
57
 
58
58
  If `omakit` is not found afterwards, npm's global `bin` is not on your PATH
59
- (measured: a prefix of `~/.local/share/lerd/node-global` whose `bin` no shell
59
+ (measured: an npm global prefix under `~/.local/share` whose `bin` no shell
60
60
  searched). Run `"$(npm prefix --global)/bin/omakit" setup` once: it prints the
61
61
  one line that puts that directory on PATH for the shell in `$SHELL`, and the
62
62
  rc file to keep it in; `omakit doctor` reports the same as `omakit.path`.
@@ -192,6 +192,11 @@ the marketplace's current HEAD when the network is there, because the pin's
192
192
  copy is stale within hours (4,201 of 4,293 commits in 30 days touched only
193
193
  `registry.json`), and from the pin with `--offline`.
194
194
 
195
+ This is why the tool is Node: the marketplace's scanner, form parser and
196
+ catalog builder are Node modules, and omakit runs them verbatim from the
197
+ pinned commit instead of reimplementing their rules, where a different
198
+ language would mean a copy that can drift.
199
+
195
200
  The security baseline is the marketplace's own code, imported unmodified and run
196
201
  over a local snapshot with no network. Omakit adds no rule, renames no outcome,
197
202
  and never restates the result as a safety claim: the baseline does no data-flow
@@ -231,16 +236,20 @@ the cache. Bumping it changes where the submission contract and the baseline
231
236
  policy are read from, and the procedure in
232
237
  [docs/UPSTREAM_CONTRACT.md](docs/UPSTREAM_CONTRACT.md) ends in re-proving
233
238
  transport parity and committing the evidence. `omakit doctor` tells you when the
234
- pin is behind the marketplace's current branch, names which of the paths omakit
235
- reads actually changed (about 140 commits a day touch only `registry.json`, so
236
- "behind" alone would be true of every run), and then leaves it alone. That the
239
+ pin is behind in something omakit reads from it, names which paths changed,
240
+ and then leaves it alone: `registry.json` and `site/catalog.json` moving is
241
+ fine, because those are read live from HEAD (about 140 commits a day touch
242
+ only `registry.json`, so "behind" alone would be true of every run); the
243
+ marketplace's code or forms moving is a note, and what you can do about it
244
+ is run `omakit upgrade`, since a newer omakit may already carry the new pin,
245
+ and otherwise open an issue naming the paths. That the
237
246
  pin can go stale unnoticed is the same defect class `omakit watch` reports, so it
238
247
  would be poor form to hide it here.
239
248
 
240
249
  ## Evidence, not claims
241
250
 
242
251
  ```bash
243
- npm test # 147 tests, node --test, no dependencies
252
+ npm test # node --test, no dependencies; green from `git archive` too
244
253
  ```
245
254
 
246
255
  | Claim | Proof |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omakit",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
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, posts nothing, zero dependencies.",
5
5
  "license": "MIT",
6
6
  "author": "Maarten Tolhuijs",
@@ -57,7 +57,9 @@ omakit verify <path-to-the-plugin-repo> --json
57
57
  ```
58
58
 
59
59
  `verify` without `--json` prints a report for a person; `--json` is the
60
- document, unchanged between releases.
60
+ document, unchanged between releases. It needs no network once the pin
61
+ exists; a reviewer-mode target (`<https url>@<40-char sha>`) is fetched once,
62
+ read-only, into omakit's own cache.
61
63
 
62
64
  Useful flags: `--notes` for the Maintainer notes field, `--suggest-tag` for the
63
65
  optional suggestion, `--name` when the manifest has no name, `--json` for a
@@ -14,6 +14,7 @@ import { createHash } from "node:crypto"
14
14
  import { execFileSync } from "node:child_process"
15
15
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
16
16
  import { join, resolve } from "node:path"
17
+ import { pathToFileURL } from "node:url"
17
18
  import { runBaseline } from "../../tools/marketplace/run-baseline.mjs"
18
19
  import { requirePin } from "../../tools/marketplace/pin.mjs"
19
20
  import { parityCorpus, strataSizes } from "./corpus.mjs"
@@ -22,14 +23,7 @@ import { token } from "../../tools/marketplace/github.mjs"
22
23
  import { omakitCacheDir } from "../../tools/marketplace/paths.mjs"
23
24
  import { parityOutput } from "../../tools/marketplace/parity-output.mjs"
24
25
 
25
- const repoRoot = resolve(process.env.OMAKIT_ROOT || process.cwd())
26
- const pinDir = requirePin(repoRoot).dir
27
- const cacheDir = omakitCacheDir("parity")
28
- const output = parityOutput({ repoRoot, out: process.env.PARITY_OUT || null })
29
- const count = Number(process.env.PARITY_COUNT || 30)
30
- const offset = Number(process.env.PARITY_OFFSET || 0)
31
-
32
- function shallowClone(repoUrl, commit) {
26
+ function shallowClone(cacheDir, repoUrl, commit) {
33
27
  const dir = join(cacheDir, subjectSlug(repoUrl))
34
28
  mkdirSync(dir, { recursive: true })
35
29
  const run = (args) => execFileSync("git", ["-C", dir, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] })
@@ -71,124 +65,162 @@ function pinCommit(dir) {
71
65
  }
72
66
  }
73
67
 
74
- const corpus = parityCorpus(pinDir, count, offset)
75
- const rows = []
76
- const ruleTotals = {}
77
- const capabilityTotals = {}
78
- let mismatches = 0
79
- let failures = 0
80
-
81
- for (const target of corpus) {
82
- const row = {
83
- repo: target.repo,
84
- commit: target.commit,
85
- type: target.type,
86
- registryOutcome: target.registryOutcome,
87
- registryOutcomeAtSameCommit: target.registryOutcomeCommit === target.commit,
88
- }
68
+ function generatorIdentity(repoRoot) {
89
69
  try {
90
- const dir = shallowClone(target.repo, target.commit)
91
- const local = await runBaseline({
92
- repoRoot,
93
- repoUrl: target.repo,
94
- commitSha: target.commit,
95
- transport: "local",
96
- repoDir: dir,
97
- })
98
- row.localOutcome = local.result.outcome
99
- row.requests = local.adapter.requests
100
- row.localDigest = digest(local.result)
101
- // Rule and capability ids are counted in the summary, unattributed; they are
102
- // deliberately not recorded against this repository.
103
- for (const finding of local.result.findings || []) {
104
- const id = finding.ruleId || finding.id
105
- if (id) ruleTotals[id] = (ruleTotals[id] || 0) + 1
106
- }
107
- for (const capability of local.result.capabilities || []) {
108
- if (capability.id) capabilityTotals[capability.id] = (capabilityTotals[capability.id] || 0) + 1
70
+ return { omakitCommit: execFileSync("git", ["-C", repoRoot, "rev-parse", "HEAD"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() }
71
+ } catch {
72
+ const pkg = JSON.parse(readFileSync(join(repoRoot, "package.json"), "utf8"))
73
+ return { omakitPackage: `${pkg.name}@${pkg.version}` }
74
+ }
75
+ }
76
+
77
+ /**
78
+ * The parity run. `cli.mjs` calls this with its own root and the flags it
79
+ * parsed; the adapter at the bottom of this file calls it from the command
80
+ * line. Everything the run needs arrives as an argument: omakit reads no
81
+ * environment variable of its own, and this handoff used to be four
82
+ * PARITY_* variables and an OMAKIT_ROOT, which was the one OMAKIT_* name in
83
+ * the tree and needed an exemption in the test that forbids them.
84
+ *
85
+ * @param {{ repoRoot: string, count?: number, offset?: number, out?: string|null, log?: (line: string) => void }} options
86
+ * `out` is an explicit evidence file; without it the evidence lands in
87
+ * `docs/evidence/parity/` under a dated name that never overwrites.
88
+ * @returns {Promise<{ summary: object, outFile: string, ok: boolean }>}
89
+ */
90
+ export async function runParity({ repoRoot, count = 30, offset = 0, out = null, log = console.log }) {
91
+ const root = resolve(repoRoot)
92
+ const pinDir = requirePin(root).dir
93
+ const cacheDir = omakitCacheDir("parity")
94
+ // Resolved before any fetch, so a packaged read-only install refuses here.
95
+ const output = parityOutput({ repoRoot: root, out })
96
+ const corpus = parityCorpus(pinDir, count, offset)
97
+ const rows = []
98
+ const ruleTotals = {}
99
+ const capabilityTotals = {}
100
+ let mismatches = 0
101
+ let failures = 0
102
+
103
+ for (const target of corpus) {
104
+ const row = {
105
+ repo: target.repo,
106
+ commit: target.commit,
107
+ type: target.type,
108
+ registryOutcome: target.registryOutcome,
109
+ registryOutcomeAtSameCommit: target.registryOutcomeCommit === target.commit,
109
110
  }
110
111
  try {
111
- const github = await runBaseline({
112
- repoRoot,
112
+ const dir = shallowClone(cacheDir, target.repo, target.commit)
113
+ const local = await runBaseline({
114
+ repoRoot: root,
113
115
  repoUrl: target.repo,
114
116
  commitSha: target.commit,
115
- transport: "github",
116
- token: token() ?? undefined,
117
+ transport: "local",
118
+ repoDir: dir,
117
119
  })
118
- row.githubOutcome = github.result.outcome
119
- row.githubDigest = digest(github.result)
120
- row.identical = row.localDigest === row.githubDigest
121
- if (!row.identical) {
122
- mismatches += 1
123
- // A mismatch is the one case worth describing, and it is described as a
124
- // difference between two transports of the same code, not as a finding
125
- // about the plugin: which keys differ, never their contents.
126
- const localPayload = JSON.parse(comparable(local.result))
127
- const githubPayload = JSON.parse(comparable(github.result))
128
- row.differingKeys = Object.keys(localPayload).filter(
129
- (key) => JSON.stringify(localPayload[key]) !== JSON.stringify(githubPayload[key]),
130
- )
120
+ row.localOutcome = local.result.outcome
121
+ row.requests = local.adapter.requests
122
+ row.localDigest = digest(local.result)
123
+ // Rule and capability ids are counted in the summary, unattributed; they are
124
+ // deliberately not recorded against this repository.
125
+ for (const finding of local.result.findings || []) {
126
+ const id = finding.ruleId || finding.id
127
+ if (id) ruleTotals[id] = (ruleTotals[id] || 0) + 1
128
+ }
129
+ for (const capability of local.result.capabilities || []) {
130
+ if (capability.id) capabilityTotals[capability.id] = (capabilityTotals[capability.id] || 0) + 1
131
+ }
132
+ try {
133
+ const github = await runBaseline({
134
+ repoRoot: root,
135
+ repoUrl: target.repo,
136
+ commitSha: target.commit,
137
+ transport: "github",
138
+ token: token() ?? undefined,
139
+ })
140
+ row.githubOutcome = github.result.outcome
141
+ row.githubDigest = digest(github.result)
142
+ row.identical = row.localDigest === row.githubDigest
143
+ if (!row.identical) {
144
+ mismatches += 1
145
+ // A mismatch is the one case worth describing, and it is described as a
146
+ // difference between two transports of the same code, not as a finding
147
+ // about the plugin: which keys differ, never their contents.
148
+ const localPayload = JSON.parse(comparable(local.result))
149
+ const githubPayload = JSON.parse(comparable(github.result))
150
+ row.differingKeys = Object.keys(localPayload).filter(
151
+ (key) => JSON.stringify(localPayload[key]) !== JSON.stringify(githubPayload[key]),
152
+ )
153
+ }
154
+ } catch (error) {
155
+ row.githubError = (error.code || "error") + ": " + error.message
156
+ failures += 1
131
157
  }
132
158
  } catch (error) {
133
- row.githubError = (error.code || "error") + ": " + error.message
159
+ row.localError = (error.code || "error") + ": " + error.message
134
160
  failures += 1
135
161
  }
136
- } catch (error) {
137
- row.localError = (error.code || "error") + ": " + error.message
138
- failures += 1
162
+ rows.push(row)
163
+ log(
164
+ [
165
+ row.identical === true ? "ok " : row.identical === false ? "DIFF" : "?? ",
166
+ target.repo.replace("https://github.com/", "").padEnd(44),
167
+ "local=" + (row.localOutcome || row.localError || "-"),
168
+ "github=" + (row.githubOutcome || row.githubError || "-"),
169
+ "registry=" + (row.registryOutcome || "-"),
170
+ ].join(" "),
171
+ )
139
172
  }
140
- rows.push(row)
141
- console.log(
142
- [
143
- row.identical === true ? "ok " : row.identical === false ? "DIFF" : "?? ",
144
- target.repo.replace("https://github.com/", "").padEnd(44),
145
- "local=" + (row.localOutcome || row.localError || "-"),
146
- "github=" + (row.githubOutcome || row.githubError || "-"),
147
- "registry=" + (row.registryOutcome || "-"),
148
- ].join(" "),
149
- )
150
- }
151
173
 
152
- function generatorIdentity() {
153
- try {
154
- return { omakitCommit: execFileSync("git", ["-C", repoRoot, "rev-parse", "HEAD"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() }
155
- } catch {
156
- const pkg = JSON.parse(readFileSync(join(repoRoot, "package.json"), "utf8"))
157
- return { omakitPackage: `${pkg.name}@${pkg.version}` }
174
+ const summary = {
175
+ generator: generatorIdentity(root),
176
+ pinnedMarketplaceCommit: pinCommit(pinDir),
177
+ corpusSize: corpus.length,
178
+ requestedCount: count,
179
+ offset,
180
+ strata: strataSizes(count),
181
+ outcomes: rows.reduce((acc, row) => {
182
+ const key = row.localOutcome || "error"
183
+ acc[key] = (acc[key] || 0) + 1
184
+ return acc
185
+ }, {}),
186
+ identical: rows.filter((row) => row.identical === true).length,
187
+ mismatches,
188
+ failures,
189
+ // Unattributed aggregates. No repository name is attached to any rule.
190
+ ruleTotals,
191
+ capabilityTotals,
192
+ publication: {
193
+ perRepositoryDetail: "omitted",
194
+ reason: "Findings about a specific third-party plugin are not published. Equality is evidenced by the sha256 digests below, which anyone can recompute from the repository, the commit and the pinned marketplace commit.",
195
+ digest: "sha256 of {outcome, disposition, blocksApproval, findings, capabilities}, first 32 hex characters",
196
+ },
197
+ rows,
158
198
  }
199
+ const date = new Date().toISOString().slice(0, 10)
200
+ const outputIsFile = Boolean(out)
201
+ const outputDir = outputIsFile ? resolve(output, "..") : output
202
+ mkdirSync(outputDir, { recursive: true })
203
+ let outFile = outputIsFile ? output : join(outputDir, `${date}-local-vs-github.json`)
204
+ for (let n = 2; !outputIsFile && existsSync(outFile); n += 1) outFile = join(outputDir, `${date}-local-vs-github-${n}.json`)
205
+ writeFileSync(outFile, JSON.stringify(summary, null, 2) + "\n")
206
+ log("\nidentical " + summary.identical + "/" + corpus.length + ", mismatches " + mismatches + ", failures " + failures + " -> " + outFile)
207
+ return { summary, outFile, ok: mismatches === 0 && failures === 0 }
159
208
  }
160
209
 
161
- const summary = {
162
- generator: generatorIdentity(),
163
- pinnedMarketplaceCommit: pinCommit(pinDir),
164
- corpusSize: corpus.length,
165
- requestedCount: count,
166
- offset,
167
- strata: strataSizes(count),
168
- outcomes: rows.reduce((acc, row) => {
169
- const key = row.localOutcome || "error"
170
- acc[key] = (acc[key] || 0) + 1
171
- return acc
172
- }, {}),
173
- identical: rows.filter((row) => row.identical === true).length,
174
- mismatches,
175
- failures,
176
- // Unattributed aggregates. No repository name is attached to any rule.
177
- ruleTotals,
178
- capabilityTotals,
179
- publication: {
180
- perRepositoryDetail: "omitted",
181
- reason: "Findings about a specific third-party plugin are not published. Equality is evidenced by the sha256 digests below, which anyone can recompute from the repository, the commit and the pinned marketplace commit.",
182
- digest: "sha256 of {outcome, disposition, blocksApproval, findings, capabilities}, first 32 hex characters",
183
- },
184
- rows,
210
+ // The command-line adapter: `node tests/parity/run.mjs [--count n] [--offset n]
211
+ // [--out file]` from the repository root. Thin on purpose; `omakit parity`
212
+ // is the same call with cli.mjs's own root.
213
+ if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
214
+ const args = process.argv.slice(2)
215
+ const flag = (name) => {
216
+ const index = args.indexOf(name)
217
+ return index >= 0 ? args[index + 1] : undefined
218
+ }
219
+ const { ok } = await runParity({
220
+ repoRoot: process.cwd(),
221
+ count: Number(flag("--count") || 30),
222
+ offset: Number(flag("--offset") || 0),
223
+ out: flag("--out") || null,
224
+ })
225
+ process.exit(ok ? 0 : 1)
185
226
  }
186
- const date = new Date().toISOString().slice(0, 10)
187
- const outputIsFile = process.env.PARITY_OUT_EXPLICIT === "1"
188
- const outputDir = outputIsFile ? resolve(output, "..") : output
189
- mkdirSync(outputDir, { recursive: true })
190
- let outFile = outputIsFile ? output : join(outputDir, `${date}-local-vs-github.json`)
191
- for (let n = 2; !outputIsFile && existsSync(outFile); n += 1) outFile = join(outputDir, `${date}-local-vs-github-${n}.json`)
192
- writeFileSync(outFile, JSON.stringify(summary, null, 2) + "\n")
193
- console.log("\nidentical " + summary.identical + "/" + corpus.length + ", mismatches " + mismatches + ", failures " + failures + " -> " + outFile)
194
- process.exit(mismatches === 0 && failures === 0 ? 0 : 1)
@@ -19,11 +19,12 @@ local commit through the transport seam the marketplace tests itself
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
21
  | `issue.mjs` | Renders the issue the way the form would, then has the marketplace's own parser judge it. |
22
- | `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. |
22
+ | `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
23
  | `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. |
24
+ | `doctor.mjs` | What is installed, what is pinned, and what has moved. `omakit.version` is one check for one question, is this the current omakit: `ok` with "0.1.8, the newest published version", a `note` with "0.1.8; 0.1.9 is published" and the upgrade command, `unknown` with the registry's failure code, `info` offline; its evidence carries `{ installed, latest, source }`. `pin.freshness` compares each path in `PIN_PATHS` between the pin and the marketplace's HEAD by tree or blob id and splits what moved by the list `registry.mjs` reads live from: `registry.json` and `site/catalog.json` moving is `ok`, because those are read from HEAD anyway; anything else moving (`scripts/`, the two forms) is a `note` naming the paths, with an action a user can take, `omakit upgrade` and then an issue at the repository named in package.json. The maintainer's pin procedure stays in `docs/UPSTREAM_CONTRACT.md` and is never printed. HEAD unreadable is `unknown`. `--json` carries `changedPaths`, `readLive` and `pinned` under the check's evidence. |
24
25
  | `watch.mjs` | The validation watch: validated commit versus current default-branch HEAD, and the one action that refreshes it. |
25
26
  | `github.mjs` | Read-only GitHub access. GET only. The credential is your `gh` login, read through one frozen `gh auth token` call, and is never written anywhere. |
26
- | `style.mjs` | The visual system, defined once: the palette, the status vocabulary, the block ramp, the columns, the motion budgets, and the composition helpers every command draws with. `docs/TUI.md` explains it. |
27
+ | `style.mjs` | The visual system, defined once: the palette, the status vocabulary, the block ramp, the columns, the motion budgets, and the composition helpers every command draws with. Six states: `▁ ok`, `█ FAIL`, `▓ note`, `░ info`, `▒ ?` for a check that could not be made, and `▔ skip` for a check a flag said not to make, the floor's ink at the ceiling so it is never read as a pass. `docs/TUI.md` explains it. |
27
28
  | `report.mjs` | Text rendering of submit, watch, doctor and verify for the agent that runs this tool, and the person reading over its shoulder. |
28
29
  | `path-hint.mjs` | Is `omakit` reachable as a bare command, and if not, the one line that makes it so for the install that is here: a symlink for a clone, the npm prefix's `bin` on PATH for a package, said for the shell in `$SHELL`. `setup` and `doctor` print it; nothing writes an rc file. |
29
30
  | `usage.mjs` | The help text, as data. |
@@ -31,6 +32,7 @@ local commit through the transport seam the marketplace tests itself
31
32
  | `banner.mjs` | The wordmark, on a bare `omakit` and in `setup` only. |
32
33
  | `effect.mjs` | The one text effect: the wordmark through `ttfx` where it is drawn, with frozen arguments, a hard budget, no colour of its own, and nothing at all when `ttfx` is not there. |
33
34
  | `progress.mjs` | The progress line, on stderr, only when a person is looking. |
35
+ | `paths.mjs` | Omakit's cache directory, following XDG, and `withHomeAbbreviated()`: a path under `$HOME` written as `~/...` for a person, applied where doctor, setup and pin render text and never where a result is built, so `--json` keeps every path absolute. |
34
36
  | `cli.mjs` | The one entry point behind `bin/omakit`, and the one register every failure is reported in. `submit` exits on the outcome: 1 for `refused`, 0 for `ready` and `listed`. |
35
37
 
36
38
  ```text
@@ -60,6 +62,19 @@ the registry recorded so the corpus always contains repositories that are not
60
62
  side rather than the findings themselves. The GitHub side uses whatever
61
63
  credential `github.mjs` resolves (a `gh` login, and only that), read-only.
62
64
 
65
+ Two rules about the output, stated as rules because each is an exception to
66
+ a wider one. Everything omakit writes itself stays within 80 columns; text
67
+ that will be posted verbatim, the marketplace's own baseline report and the
68
+ issue body rendered from the pinned form, is never wrapped and may exceed 80,
69
+ because a wrapped body would not be the body. And stdout is the whole result;
70
+ stderr carries interactive decoration, the progress line from `progress.mjs`
71
+ and the chooser from `ask.mjs`, only when stderr is a TTY, so a piped stderr
72
+ is empty on success, and failures go to stderr always. `tests/unit/cli.test.mjs`
73
+ holds both: the width test names the two verbatim regions by their heading
74
+ and holds every other line to 80, the three-way test asserts the empty pipe,
75
+ and a pseudo-terminal test asserts that a terminal's stderr gets the progress
76
+ sequences and nothing else.
77
+
63
78
  `parity`, `watch`, `doctor` and `submit` reach the network, with Node's
64
79
  built-in `fetch`, which does not read proxy environment variables by default.
65
80
  Behind a proxy, run them with `NODE_USE_ENV_PROXY=1`. `submit` reads two things
@@ -29,8 +29,7 @@ import { progress } from "./progress.mjs"
29
29
  import { banner, bannerEnabled } from "./banner.mjs"
30
30
  import { COMMANDS, renderSummary, renderUsage, TAGLINE } from "./usage.mjs"
31
31
  import { action, colourEnabled, GUTTER, labelled, mark, styler, wrap } from "./style.mjs"
32
- import { omakitCacheDir } from "./paths.mjs"
33
- import { parityOutput } from "./parity-output.mjs"
32
+ import { omakitCacheDir, withHomeAbbreviated } from "./paths.mjs"
34
33
 
35
34
  const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..")
36
35
 
@@ -84,6 +83,18 @@ function positionals(args) {
84
83
  return args.filter((value, index) => !value.startsWith("--") && !valued.has(args[index - 1]))
85
84
  }
86
85
 
86
+ /**
87
+ * The progress line for a command, or nothing under --json: a machine
88
+ * reading the document on stdout gets no decoration on stderr either.
89
+ * Measured on 0.1.6: submit, watch and doctor silenced it and verify did
90
+ * not, so `verify --json` at a terminal drew a progress line the others
91
+ * never drew.
92
+ */
93
+ const SILENT = Object.freeze({ phase: () => {}, done: () => {} })
94
+ function spinnerFor(args) {
95
+ return args.includes("--json") ? SILENT : progress()
96
+ }
97
+
87
98
  function emit(args, text) {
88
99
  const out = option(args, "--out")
89
100
  if (out) {
@@ -100,7 +111,7 @@ async function cmdSubmit(args) {
100
111
  const target = positionals(args)[0]
101
112
  if (!target) fail("usage", "submit needs a target: `omakit submit <target> --category <c> --tags <a,b>`", 2)
102
113
  const json = args.includes("--json")
103
- const spinner = json ? { phase: () => {}, done: () => {} } : progress()
114
+ const spinner = spinnerFor(args)
104
115
  // A missing --category or --tags on an unlisted plugin is asked for, once
105
116
  // each, when a person is at a terminal on both ends and no machine is
106
117
  // reading the result. Anything else, a pipe, an agent, --json, gets the
@@ -156,7 +167,7 @@ async function cmdSubmit(args) {
156
167
  async function cmdWatch(args) {
157
168
  const issueUrl = positionals(args)[0]
158
169
  if (!issueUrl) fail("usage", "watch needs an issue: `omakit watch <issue-url>`", 2)
159
- const spinner = args.includes("--json") ? { phase: () => {}, done: () => {} } : progress()
170
+ const spinner = spinnerFor(args)
160
171
  let result
161
172
  try {
162
173
  result = await validationWatch({ repoRoot: ROOT, issueUrl, onPhase: spinner.phase })
@@ -189,7 +200,7 @@ async function cmdUpgrade(args) {
189
200
  }
190
201
 
191
202
  async function cmdDoctor(args) {
192
- const spinner = args.includes("--json") ? { phase: () => {}, done: () => {} } : progress()
203
+ const spinner = spinnerFor(args)
193
204
  const result = await doctor({ repoRoot: ROOT, offline: args.includes("--offline"), onPhase: spinner.phase })
194
205
  spinner.done()
195
206
  emit(args, args.includes("--json") ? `${JSON.stringify(result, null, 2)}\n` : renderDoctor(result))
@@ -206,7 +217,7 @@ async function cmdVerify(args) {
206
217
  if (error instanceof SubjectError) fail(error.code, error.message, error.code === "usage" ? 2 : 1)
207
218
  throw error
208
219
  }
209
- const spinner = progress()
220
+ const spinner = spinnerFor(args)
210
221
  let section
211
222
  try {
212
223
  spinner.phase("running the official security baseline over a local snapshot")
@@ -239,23 +250,27 @@ async function cmdVerify(args) {
239
250
  }
240
251
 
241
252
  async function cmdParity(args) {
242
- const count = option(args, "--count")
243
- const offset = option(args, "--offset")
244
- if (count) process.env.PARITY_COUNT = count
245
- if (offset) process.env.PARITY_OFFSET = offset
253
+ // The runner takes everything as arguments. Before 0.1.8 this handed over
254
+ // four PARITY_* variables and an OMAKIT_ROOT through the process
255
+ // environment, and OMAKIT_ROOT was the one OMAKIT_* name in the tree.
256
+ let ok = false
246
257
  try {
247
- const out = option(args, "--out")
248
- process.env.PARITY_OUT = parityOutput({ repoRoot: ROOT, out })
249
- if (out) process.env.PARITY_OUT_EXPLICIT = "1"
250
- } catch (error) {
251
- failFrom(error)
252
- }
253
- process.env.OMAKIT_ROOT = ROOT
254
- try {
255
- await import("../../tests/parity/run.mjs")
258
+ // Imported here, not at the top: the runner ships with the package, but
259
+ // no other command needs it, and a copy of bin/ and tools/ alone runs
260
+ // everything else.
261
+ const { runParity } = await import("../../tests/parity/run.mjs")
262
+ const count = option(args, "--count")
263
+ const offset = option(args, "--offset")
264
+ ;({ ok } = await runParity({
265
+ repoRoot: ROOT,
266
+ count: count ? Number(count) : undefined,
267
+ offset: offset ? Number(offset) : undefined,
268
+ out: option(args, "--out") || null,
269
+ }))
256
270
  } catch (error) {
257
271
  failFrom(error)
258
272
  }
273
+ process.exit(ok ? 0 : 1)
259
274
  }
260
275
 
261
276
  const [command, ...rest] = process.argv.slice(2)
@@ -269,7 +284,7 @@ if (command === "setup") {
269
284
  // ensurePin narrates: a state line to keep, then a fetch it is about to
270
285
  // start. The fetch is the slow part, so it gets the progress line.
271
286
  if (line.state === "fetching") spinner.phase(line.text)
272
- else process.stdout.write(`${mark(line.state, c)}${wrap(line.text, { indent: GUTTER }, c).join("\n").trimStart()}\n`)
287
+ else process.stdout.write(`${mark(line.state, c)}${wrap(withHomeAbbreviated(line.text), { indent: GUTTER }, c).join("\n").trimStart()}\n`)
273
288
  })
274
289
  } catch (error) {
275
290
  spinner.done()
@@ -13,6 +13,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
13
13
  import { basename, dirname, join } from "node:path"
14
14
  import { tagSlug } from "./form.mjs"
15
15
  import { COMMANDS, COMPLETION_SHELLS } from "./usage.mjs"
16
+ import { withHomeAbbreviated } from "./paths.mjs"
16
17
 
17
18
  /**
18
19
  * The completion model, read out of the help data. A subcommand is the word
@@ -221,7 +222,7 @@ export function completionInstall(env = process.env) {
221
222
  const shell = basename(env.SHELL || "")
222
223
  const home = env.HOME || ""
223
224
  if (!home || !COMPLETION_SHELLS.includes(shell)) return null
224
- const tilde = (dir) => (dir.startsWith(home) ? `~${dir.slice(home.length)}` : dir)
225
+ const tilde = (dir) => withHomeAbbreviated(dir, env)
225
226
  if (shell === "bash") {
226
227
  const dir = join(env.XDG_DATA_HOME || join(home, ".local/share"), "bash-completion/completions")
227
228
  return { shell, path: join(dir, "omakit"), display: `${tilde(dir)}/omakit`, note: null }
@@ -13,33 +13,51 @@
13
13
  // contract is read from, and the procedure in docs/UPSTREAM_CONTRACT.md requires
14
14
  // re-proving transport parity and committing the evidence afterwards. An
15
15
  // `upgrade` that quietly advanced the pin would break the one guarantee this
16
- // tool sells. So doctor reports that the pin is behind and prints the procedure.
16
+ // tool sells. So doctor reports that the pin is behind, and that is all it
17
+ // does: the procedure is the maintainer's, it lives in that document and in
18
+ // this comment, and it is never printed, because the person running doctor
19
+ // is a user of a package that does not even ship docs/. What a user can do
20
+ // is run `omakit upgrade`, since a newer omakit may already carry the new
21
+ // pin, and otherwise open an issue naming the paths that moved.
17
22
  //
18
23
  // That the pin goes stale unnoticed is, of course, exactly the defect class
19
24
  // `omakit watch` exists to report. It would be poor form not to apply it here.
20
25
  //
21
26
  // And "behind" is not the same as "behind in something omakit reads". Measured
22
27
  // on 2026-09-13 (docs/MEASUREMENTS.md M7): 4,201 of the marketplace's 4,293
23
- // commits in 30 days touched only registry.json, which omakit now reads live,
28
+ // commits in 30 days touched only registry.json, which omakit reads live,
24
29
  // while nothing under scripts/ or .github/ISSUE_TEMPLATE/ changed since the
25
30
  // pin. A doctor that only said "behind" would say it about every run. So it
26
31
  // compares each path in PIN_PATHS between the pin and HEAD, by tree or blob
27
- // id, and names the ones that moved.
32
+ // id, and splits the ones that moved by the same list registry.mjs reads
33
+ // live from: the two data files, which a moved HEAD cannot make stale, and
34
+ // everything else, which only a new pin can carry. Measured on 0.1.7: with
35
+ // only registry.json and site/catalog.json moved, doctor said `note` and
36
+ // pointed a user at docs/UPSTREAM_CONTRACT.md, though the pin was behind in
37
+ // nothing the tool uses.
28
38
 
29
39
  import { execFileSync } from "node:child_process"
30
40
  import { readFileSync } from "node:fs"
31
41
  import { join } from "node:path"
32
42
  import { MARKETPLACE_PIN, PIN_PATHS, marketplacePinDir, pinDiskUsage, pinIsSparse, requirePin } from "./pin.mjs"
43
+ import { LIVE_PATHS } from "./registry.mjs"
33
44
  import { credential, defaultBranchHead, getJson, UNAUTHENTICATED_LIMIT, GitHubError } from "./github.mjs"
34
- import { latestOnRegistry, upgradeCommand } from "./upgrade.mjs"
45
+ import { NPM_REGISTRY, registryLatest, upgradeCommand } from "./upgrade.mjs"
35
46
  import { pathHint } from "./path-hint.mjs"
36
47
 
48
+ /** "git+https://github.com/owner/name.git" in package.json -> "https://github.com/owner/name", or null. */
49
+ function repositoryPage(repository) {
50
+ const url = typeof repository === "string" ? repository : repository?.url
51
+ const match = String(url || "").match(/github\.com[/:]([^/]+)\/([^/]+?)(?:\.git)?\/?$/)
52
+ return match ? `https://github.com/${match[1]}/${match[2]}` : null
53
+ }
54
+
37
55
  function tool(repoRoot) {
38
56
  try {
39
57
  const pkg = JSON.parse(readFileSync(join(repoRoot, "package.json"), "utf8"))
40
- return { name: pkg.name, version: pkg.version, engines: pkg.engines?.node || null }
58
+ return { name: pkg.name, version: pkg.version, engines: pkg.engines?.node || null, repository: repositoryPage(pkg.repository) }
41
59
  } catch {
42
- return { name: "omakit", version: "unknown", engines: null }
60
+ return { name: "omakit", version: "unknown", engines: null, repository: null }
43
61
  }
44
62
  }
45
63
 
@@ -102,40 +120,65 @@ export async function changedPinPaths({ pinDir, headCommit, pinCommit = MARKETPL
102
120
  return changed
103
121
  }
104
122
 
123
+ /** A PIN_PATHS pattern as a path: "/site/catalog.json" -> "site/catalog.json". */
124
+ const asPath = (pattern) => pattern.replace(/^\/|\/$/g, "")
125
+
126
+ /** "a", "a and b", "a, b and c". */
127
+ function list(items) {
128
+ return items.length < 3 ? items.join(" and ") : `${items.slice(0, -1).join(", ")} and ${items.at(-1)}`
129
+ }
130
+
105
131
  /**
106
- * Full pin evidence for machines; human output deliberately keeps short
107
- * hashes. `changedPaths` is the answer from changedPinPaths(); null when the
108
- * comparison was not made, and then the detail says only that HEAD moved.
132
+ * The pin against the marketplace's HEAD, for a user. `changedPaths` is the
133
+ * answer from changedPinPaths(), split by LIVE_PATHS into `readLive`, the
134
+ * data files a moved HEAD cannot make stale because registry.mjs reads them
135
+ * from HEAD, and `pinned`, everything only a new pin can carry. Only the
136
+ * second set is worth a note; the action for it is a user's, not the
137
+ * maintainer's. Null when the comparison was not made, and then the detail
138
+ * says only that HEAD moved. Full commits in the evidence; short ones in
139
+ * the detail. `issues` is where a user reports a pinned path that moved,
140
+ * read from package.json by the caller.
109
141
  */
110
- export function pinFreshness(identity, head, changedPaths = null) {
142
+ export function pinFreshness(identity, head, changedPaths = null, { issues = null } = {}) {
111
143
  const current = head.commit === identity.commit
112
144
  const branch = head.branch || "default"
145
+ const changed = current ? [] : changedPaths || []
146
+ const readLive = changed.filter((pattern) => LIVE_PATHS.includes(asPath(pattern)))
147
+ const pinned = changed.filter((pattern) => !LIVE_PATHS.includes(asPath(pattern)))
148
+ const where = `pin ${identity.commit.slice(0, 7)}; marketplace ${branch} at ${head.commit.slice(0, 7)}`
113
149
  const moved = changedPaths === null
114
- ? ""
115
- : changedPaths.length
116
- ? `; changed since the pin: ${changedPaths.join(", ")}`
117
- : "; every path omakit reads is unchanged since the pin"
150
+ ? "; the paths omakit reads were not compared"
151
+ : pinned.length
152
+ ? `; changed since the pin: ${pinned.join(", ")}${readLive.length ? ` (${list(readLive.map(asPath))} moved too, and ${readLive.length === 1 ? "that is" : "those are"} read live)` : ""}`
153
+ : readLive.length
154
+ ? `; only ${list(readLive.map(asPath))} moved, and ${readLive.length === 1 ? "that is" : "those are"} read live`
155
+ : "; nothing omakit reads moved"
156
+ const stale = !current && (changedPaths === null || pinned.length > 0)
118
157
  return {
119
158
  id: "pin.freshness",
120
- state: current ? "ok" : "advice",
121
- detail: current
122
- ? `the pin is the marketplace's current ${branch}-branch HEAD`
123
- : `the pin is ${identity.commit.slice(0, 7)}; the marketplace's ${branch} branch is now at ${head.commit.slice(0, 7)}${moved}`,
124
- action: current ? null : "Bumping the pin is a deliberate change: docs/UPSTREAM_CONTRACT.md has the procedure, which ends in re-proving parity and committing its evidence. Nothing here does it for you.",
159
+ state: stale ? "advice" : "ok",
160
+ detail: current ? `the pin is the marketplace's current ${branch}-branch HEAD` : `${where}${moved}`,
161
+ action: stale
162
+ ? `A newer omakit may already carry the new pin: run \`omakit upgrade\`. If it does not, open an issue${issues ? ` at ${issues}/issues` : ""} naming the paths above.`
163
+ : null,
125
164
  evidence: {
126
165
  pinCommit: identity.commit,
127
166
  marketplaceHead: head.commit,
128
167
  branch,
129
- ...(changedPaths === null ? {} : { changedPaths: current ? [] : changedPaths }),
168
+ ...(changedPaths === null ? {} : { changedPaths: changed, readLive, pinned }),
130
169
  },
131
170
  }
132
171
  }
133
172
 
134
173
  /**
135
- * @param {{ repoRoot: string, offline?: boolean, env?: object, npmPrefix?: () => string|null }} options
136
- * `env` and `npmPrefix` are injectable for tests of the PATH check.
174
+ * @param {{ repoRoot: string, offline?: boolean, env?: object, npmPrefix?: () => string|null,
175
+ * resolveHead?: typeof defaultBranchHead, latest?: typeof registryLatest }} options
176
+ * `env` and `npmPrefix` are injectable for tests of the PATH check;
177
+ * `resolveHead` and `latest` for tests of the two checks that read the
178
+ * network, whose defaults are the tool's one HEAD resolver and its one
179
+ * registry read.
137
180
  */
138
- export async function doctor({ repoRoot, offline = false, onPhase, env = process.env, npmPrefix }) {
181
+ export async function doctor({ repoRoot, offline = false, onPhase, env = process.env, npmPrefix, resolveHead = defaultBranchHead, latest: latestVersion = registryLatest }) {
139
182
  // Optional: told what is being read while the network answers. Never
140
183
  // affects the result.
141
184
  const phase = onPhase || (() => {})
@@ -149,7 +192,27 @@ export async function doctor({ repoRoot, offline = false, onPhase, env = process
149
192
  })
150
193
 
151
194
  const self = tool(repoRoot)
152
- add("omakit.version", "info", `${self.name} ${self.version}`)
195
+ // What is installed and what is published, one check: the two facts are
196
+ // one question, "is this the current omakit", and were two lines before.
197
+ // Offline, the first fact alone, as information. The evidence carries both
198
+ // and where the second came from.
199
+ const versionCheck = (state, detail, action = null, latest = null, source = null) =>
200
+ add("omakit.version", state, detail, action, { installed: self.version, latest, source })
201
+ if (offline) {
202
+ versionCheck("info", `${self.version}; the newest published version is not checked (--offline)`)
203
+ } else {
204
+ phase("asking the npm registry for the newest published version")
205
+ const published = await latestVersion(self.name)
206
+ if (published.version) {
207
+ const current = published.version === self.version
208
+ versionCheck(current ? "ok" : "advice",
209
+ current ? `${self.version}, the newest published version` : `${self.version}; ${published.version} is published`,
210
+ current ? null : `run \`${upgradeCommand(repoRoot, self.name)}\``,
211
+ published.version, NPM_REGISTRY)
212
+ } else {
213
+ versionCheck("unknown", `${self.version}; could not read the npm registry (${published.error?.code || "error"})`)
214
+ }
215
+ }
153
216
 
154
217
  // Reachable as a bare command, or the one line that makes it so for this
155
218
  // install (path-hint.mjs). Measured: an npm prefix whose bin is not on PATH
@@ -185,29 +248,19 @@ export async function doctor({ repoRoot, offline = false, onPhase, env = process
185
248
  if (!offline && identity) {
186
249
  phase("reading the marketplace's current default-branch HEAD")
187
250
  try {
188
- const head = await defaultBranchHead(MARKETPLACE_PIN.repository)
251
+ const head = await resolveHead(MARKETPLACE_PIN.repository)
189
252
  let changedPaths = []
190
253
  if (head.commit !== identity.commit) {
191
254
  phase("comparing each path omakit reads between the pin and HEAD")
192
255
  changedPaths = await changedPinPaths({ pinDir: dir, headCommit: head.commit, pinCommit: identity.commit })
193
256
  }
194
- checks.push(pinFreshness(identity, head, changedPaths))
257
+ checks.push(pinFreshness(identity, head, changedPaths, { issues: self.repository }))
195
258
  } catch (error) {
196
259
  add("pin.freshness", "unknown", `could not read the marketplace's HEAD (${error.code || "error"})`,
197
260
  error.code === "network-unavailable" ? "Connect to the network, or pass --offline to skip the two checks that need it." : null,
198
261
  { pinCommit: identity.commit, marketplaceHead: null, branch: null })
199
262
  }
200
263
 
201
- phase("asking the npm registry for the newest published version")
202
- const latest = await latestOnRegistry(self.name)
203
- if (latest) {
204
- const current = latest === self.version
205
- add("omakit.latest", current ? "ok" : "advice",
206
- current ? `${latest} is the newest published version` : `${latest} is published, this is ${self.version}`,
207
- current ? null : upgradeCommand(repoRoot, self.name))
208
- } else {
209
- add("omakit.latest", "unknown", "the npm registry did not answer, or this version is unpublished")
210
- }
211
264
  }
212
265
 
213
266
  // Where the credential comes from, said out loud. Borrowing someone's `gh`
@@ -2,7 +2,7 @@
2
2
  // it so, for the install that is actually here.
3
3
  //
4
4
  // Measured: after `npm install --global omakit` on a machine whose npm prefix
5
- // was ~/.local/share/lerd/node-global, the command was missing, because that
5
+ // was a directory under ~/.local/share, the command was missing, because that
6
6
  // prefix's `bin` was not on PATH. `omakit setup` then printed the `ln -s` hint
7
7
  // written for a clone, which points at a bin/omakit that npm did not lay out
8
8
  // where the hint assumes. An npm install needs the npm prefix's `bin` on PATH,
@@ -8,3 +8,21 @@ export function omakitCacheDir(name = "", env = process.env) {
8
8
  : join(env.HOME || homedir(), ".cache")
9
9
  return join(base, "omakit", name)
10
10
  }
11
+
12
+ /**
13
+ * Text for a person, with every path under the home directory written the
14
+ * way a shell would take it: `~/.cache/omakit/marketplace`. Only `$HOME`
15
+ * counts, because `~` is what the shell expands to `$HOME` and nothing else;
16
+ * with it unset, or for a path outside it, the text is returned as it came.
17
+ * Human output only: `--json` keeps every path absolute, so this is applied
18
+ * where text is rendered, never where a result is built.
19
+ */
20
+ export function withHomeAbbreviated(text, env = process.env) {
21
+ const home = String(env.HOME || "").replace(/\/+$/, "")
22
+ if (!home || !home.startsWith("/")) return String(text)
23
+ // The home directory where a path starts: at the start of the text or after
24
+ // the characters a path follows in prose or a command, and followed by a
25
+ // separator or the end, so /tmp/home/me/x and /home/meh/x are left alone.
26
+ const literal = home.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
27
+ return String(text).replace(new RegExp(`(^|[\\s"'(=:])${literal}(?=/|$|[\\s"'),:])`, "g"), "$1~")
28
+ }
@@ -20,13 +20,15 @@
20
20
  import {
21
21
  action, colourEnabled, COLUMNS, continuation, field, GUTTER, labelled, mark, section, STEP, styler, verdict, width, wrap,
22
22
  } from "./style.mjs"
23
+ import { withHomeAbbreviated } from "./paths.mjs"
23
24
 
24
25
  const body = " ".repeat(GUTTER)
25
26
 
26
- /** The state a check renders in: an advisory failure is a note, not a FAIL, and a check that waited on another is a question. */
27
+ /** The state a check renders in: an advisory failure is a note, not a FAIL, a check that waited on another is a question, and a check a flag skipped says so. */
27
28
  function stateOf(check) {
28
29
  if (check.verdict === "pass") return "pass"
29
30
  if (check.verdict === "unknown") return "unknown"
31
+ if (check.verdict === "skipped") return "skipped"
30
32
  return check.severity === "advisory" ? "advisory" : "fail"
31
33
  }
32
34
 
@@ -101,10 +103,10 @@ export function renderSubmit(result, { colour = colourEnabled() } = {}) {
101
103
  out.push(...field("marketplace", `${result.pin.commit}, baseline ${result.pin.baselineVersion}, ${result.pin.enforcementMode}`, c))
102
104
  out.push("")
103
105
 
104
- // Passing checks run together, and so does a check that waited on another:
105
- // both are two quiet lines. Anything else gets a blank line on each side,
106
- // collapsed where two blocks meet.
107
- const quiet = (state) => state === "pass" || state === "unknown"
106
+ // Passing checks run together, and so do a check that waited on another
107
+ // and one a flag skipped: all are two quiet lines. Anything else gets a
108
+ // blank line on each side, collapsed where two blocks meet.
109
+ const quiet = (state) => state === "pass" || state === "unknown" || state === "skipped"
108
110
  let previous = "pass"
109
111
  for (const [index, check] of result.checks.entries()) {
110
112
  const state = stateOf(check)
@@ -168,7 +170,12 @@ export function renderSubmit(result, { colour = colourEnabled() } = {}) {
168
170
  const validation = result.validationCommit.defaultBranchHead
169
171
  ? `${result.validationCommit.local} is the ${result.validationCommit.branch || "default"}-branch HEAD, so it is the commit the marketplace will validate.`
170
172
  : `${result.validationCommit.local} is the local commit; the marketplace validates the default-branch HEAD it resolves when the issue is opened.`
171
- out.push(...verdict("pass", "READY", `every blocking check passed. ${validation}`, c))
173
+ // A skipped check is said on the READY line itself, so the one line an
174
+ // agent quotes does not read as "everything passed" when one check never
175
+ // ran. The count is the whole `skipped` list; today only --offline skips.
176
+ const skipped = (result.skipped || []).length
177
+ const skippedNote = skipped ? ` ${skipped === 1 ? "1 check" : `${skipped} checks`} skipped (--offline).` : ""
178
+ out.push(...verdict("pass", "READY", `every blocking check passed.${skippedNote} ${validation}`, c))
172
179
  out.push("")
173
180
  out.push(...section("issue title", c))
174
181
  out.push(c("heading", result.issue.title))
@@ -311,7 +318,13 @@ export function renderVerify(document, { colour = colourEnabled(), blockingRules
311
318
 
312
319
  const DOCTOR_STATE = { ok: "pass", advice: "advisory", problem: "fail", info: "info", unknown: "unknown" }
313
320
 
314
- export function renderDoctor(result, { colour = colourEnabled() } = {}) {
321
+ /**
322
+ * @param {{ colour?: boolean, env?: object }} [options] `env` is where `$HOME`
323
+ * is read from: a path under it is printed as `~/...` for a person, the
324
+ * way a shell takes it, while the result itself, and so `--json`, keeps
325
+ * every path absolute.
326
+ */
327
+ export function renderDoctor(result, { colour = colourEnabled(), env = process.env } = {}) {
315
328
  const c = styler(colour)
316
329
  const out = []
317
330
  let previous = false
@@ -320,8 +333,8 @@ export function renderDoctor(result, { colour = colourEnabled() } = {}) {
320
333
  const loud = state === "fail" || state === "advisory"
321
334
  if (index > 0 && (loud || previous)) out.push("")
322
335
  out.push(`${mark(state, c)}${c("name", check.id)}`)
323
- out.push(...wrap(check.detail, { indent: GUTTER }, c))
324
- if (check.action) out.push(...action(check.action, c))
336
+ out.push(...wrap(withHomeAbbreviated(check.detail, env), { indent: GUTTER }, c))
337
+ if (check.action) out.push(...action(withHomeAbbreviated(check.action, env), c))
325
338
  previous = loud
326
339
  }
327
340
  out.push("")
@@ -20,6 +20,7 @@ import { TAGLINE } from "./usage.mjs"
20
20
  import { installCompletion } from "./completion.mjs"
21
21
  import { submissionContract } from "./form.mjs"
22
22
  import { pathHint } from "./path-hint.mjs"
23
+ import { withHomeAbbreviated } from "./paths.mjs"
23
24
 
24
25
  function version(command) {
25
26
  try {
@@ -32,16 +33,18 @@ function version(command) {
32
33
  }
33
34
 
34
35
  /**
35
- * @param {{ repoRoot: string, entryPoint: string, stream?: NodeJS.WriteStream }} options
36
+ * @param {{ repoRoot: string, entryPoint: string, stream?: NodeJS.WriteStream, env?: object }} options
37
+ * `env` is where `$HOME` is read from: this is output for a person, so a
38
+ * path under it is printed as `~/...`.
36
39
  */
37
- export async function setup({ repoRoot, entryPoint, stream = process.stdout }) {
40
+ export async function setup({ repoRoot, entryPoint, stream = process.stdout, env = process.env }) {
38
41
  const c = styler(colourEnabled(stream))
39
42
  const out = (line = "") => stream.write(`${line}\n`)
40
43
  // A step is a status line: the mark, then the fact, wrapped under itself.
41
- const step = (state, text) => out(`${mark(state, c)}${wrap(text, { indent: GUTTER }, c).join("\n").trimStart()}`)
44
+ const step = (state, text) => out(`${mark(state, c)}${wrap(withHomeAbbreviated(text, env), { indent: GUTTER }, c).join("\n").trimStart()}`)
42
45
  // The one action under a step sits in the step's body; under a sentence it
43
46
  // sits where the sentence does.
44
- const fix = (text, indent = GUTTER) => { for (const line of action(text, c, { indent })) out(line) }
47
+ const fix = (text, indent = GUTTER) => { for (const line of action(withHomeAbbreviated(text, env), c, { indent })) out(line) }
45
48
 
46
49
  // The one place the wordmark runs through `ttfx` (effect.mjs): a first run
47
50
  // already spending seconds fetching the pin.
@@ -162,6 +162,7 @@ export const DENSITY = Object.freeze({
162
162
  medium: "▒", // ▒ unknown: neither here nor there
163
163
  light: "░", // ░ information: present, weightless
164
164
  floor: "▁", // ▁ settled: a pass, a rule, the track the scanner runs on
165
+ ceiling: "▔", // ▔ skipped: the floor's own ink, never landed, because a flag said not to look
165
166
  })
166
167
 
167
168
  /** The glyph that starts the one line that fixes things. */
@@ -170,15 +171,23 @@ export const ARROW = "→"
170
171
  // --- the status vocabulary ---------------------------------------------------
171
172
 
172
173
  /**
173
- * Five states, and only five. Each has one glyph, one word and one tint, and
174
+ * Six states, and only six. Each has one glyph, one word and one tint, and
174
175
  * every command prints them through `mark()` below. `pass` and `fail` are
175
176
  * verdicts; `advisory` is a failure that does not block; `info` is a fact with
176
- * no verdict; `unknown` is a check that could not be made.
177
+ * no verdict; `unknown` is a check that could not be made; `skipped` is a
178
+ * check that was not made because a flag said not to (`--offline`).
177
179
  *
178
180
  * The glyph is the density ramp read as severity: the more ink, the more it
179
181
  * matters. The word is the same thing in letters, and its case carries it too:
180
182
  * FAIL is the only upper-case mark, so it is the one the eye lands on in a
181
183
  * column of lower-case ones, with or without colour.
184
+ *
185
+ * `skipped` and `unknown` are one family: neither has a verdict, both wear the
186
+ * same tint, and only the glyph and the word tell them apart. Measured on
187
+ * 0.1.6: `--offline` drew the skipped validation-commit check as `▁ ok`, and
188
+ * `--json` said `"verdict": "pass"`, for a comparison that never happened. The
189
+ * skipped mark is the floor's ink at the ceiling: the same weight as a pass,
190
+ * visibly not landed on one, on a theme that shows no hue.
182
191
  */
183
192
  export const STATUS = Object.freeze({
184
193
  pass: Object.freeze({ glyph: DENSITY.floor, word: "ok", tint: "pass" }),
@@ -186,6 +195,7 @@ export const STATUS = Object.freeze({
186
195
  advisory: Object.freeze({ glyph: DENSITY.dark, word: "note", tint: "advisory" }),
187
196
  info: Object.freeze({ glyph: DENSITY.light, word: "info", tint: "info" }),
188
197
  unknown: Object.freeze({ glyph: DENSITY.medium, word: "?", tint: "unknown" }),
198
+ skipped: Object.freeze({ glyph: DENSITY.ceiling, word: "skip", tint: "unknown" }),
189
199
  })
190
200
 
191
201
  /** The width of the widest mark, "█ FAIL"; every mark is padded to it so the names beside them align. */
@@ -360,7 +370,7 @@ export function wrap(text, { indent = 0, width: total = COLUMNS, first = indent
360
370
  * after it starts at GUTTER. This is the only place a status is turned into
361
371
  * characters.
362
372
  *
363
- * @param {"pass"|"fail"|"advisory"|"info"|"unknown"} state
373
+ * @param {"pass"|"fail"|"advisory"|"info"|"unknown"|"skipped"} state
364
374
  * @param {(name: string, text: string) => string} c
365
375
  */
366
376
  export function mark(state, c) {
@@ -56,7 +56,7 @@ export class SubmitError extends Error {
56
56
  }
57
57
 
58
58
  /**
59
- * A check has three verdicts. `pass` and `fail` are its own. `unknown` is
59
+ * A check has four verdicts. `pass` and `fail` are its own. `unknown` is
60
60
  * for a check that could not run because one it depends on failed: it is
61
61
  * rendered as a question, its detail names what it waited on, and it counts
62
62
  * in neither `blocking` nor `advisory`, so a refusal lists root causes only.
@@ -65,19 +65,28 @@ export class SubmitError extends Error {
65
65
  * checklist and official-parser each failed for want of a body nobody could
66
66
  * render yet, and the closing refusal listed them with "not rendered" where
67
67
  * a remedy goes.
68
+ *
69
+ * `skipped` is for a check that was not made because a flag said not to,
70
+ * independent of `waitedOn`: it never blocks, it counts in `skipped` and
71
+ * not in `unknown`, and its detail says which flag. Measured on 0.1.6:
72
+ * `--offline` handed `verdict: true` to the validation-commit check, so
73
+ * `--json` said `"verdict": "pass"` and the report drew `▁ ok` for a
74
+ * comparison that never happened, and an agent reading `checks` rather than
75
+ * the prose could tell the owner every check passed.
68
76
  */
69
77
  function check(id, fields) {
70
78
  const waitedOn = (fields.waitedOn || []).filter(Boolean)
79
+ const skipped = fields.skipped === true
71
80
  return {
72
81
  id,
73
82
  source: fields.source,
74
83
  why: fields.why,
75
84
  severity: fields.severity || "blocking",
76
- verdict: waitedOn.length ? "unknown" : fields.verdict ? "pass" : "fail",
77
- detail: waitedOn.length ? `not checked: it needs ${waitedOn.join(" and ")} to pass first` : fields.detail || "",
85
+ verdict: skipped ? "skipped" : waitedOn.length ? "unknown" : fields.verdict ? "pass" : "fail",
86
+ detail: !skipped && waitedOn.length ? `not checked: it needs ${waitedOn.join(" and ")} to pass first` : fields.detail || "",
78
87
  paths: fields.paths || [],
79
88
  // One arrow, or one per cause in the order they should be read.
80
- remedy: waitedOn.length ? null : Array.isArray(fields.remedy) ? (fields.remedy.length ? fields.remedy : null) : fields.remedy || null,
89
+ remedy: skipped || waitedOn.length ? null : Array.isArray(fields.remedy) ? (fields.remedy.length ? fields.remedy : null) : fields.remedy || null,
81
90
  }
82
91
  }
83
92
 
@@ -405,7 +414,8 @@ export async function submitPreflight(options) {
405
414
  source: "omakit",
406
415
  why: "The marketplace validates the default-branch HEAD it resolves when the issue is opened or edited, not the commit checked here. 73% of the 464 submissions parked in the author's court have a HEAD ahead of their validated commit, so a preflight against a commit that is not the pushed HEAD describes a tree nobody will review. Not a marketplace rule; an Omakit refusal to report on the wrong tree.",
407
416
  severity: options.offline ? "advisory" : "blocking",
408
- verdict: options.offline ? true : validationMatches === true,
417
+ skipped: options.offline === true,
418
+ verdict: validationMatches === true,
409
419
  detail: options.offline
410
420
  ? `not checked (--offline). Local commit ${subject.commit}.`
411
421
  : head
@@ -454,6 +464,7 @@ export async function submitPreflight(options) {
454
464
  const blocking = checks.filter((entry) => entry.severity === "blocking" && entry.verdict === "fail")
455
465
  const advisory = checks.filter((entry) => entry.severity === "advisory" && entry.verdict === "fail")
456
466
  const unknown = checks.filter((entry) => entry.verdict === "unknown")
467
+ const skipped = checks.filter((entry) => entry.verdict === "skipped")
457
468
  // Three outcomes. `refused`: a blocking check failed and no body exists.
458
469
  // `listed`: nothing failed and the plugin is already listed by this
459
470
  // repository, so there is no body either, and nothing is wrong. `ready`:
@@ -506,6 +517,7 @@ export async function submitPreflight(options) {
506
517
  blocking: blocking.map((entry) => entry.id),
507
518
  advisory: advisory.map((entry) => entry.id),
508
519
  unknown: unknown.map((entry) => entry.id),
520
+ skipped: skipped.map((entry) => entry.id),
509
521
  issue: ready ? issue : null,
510
522
  baseline: preflight.invoked
511
523
  ? {
@@ -59,11 +59,29 @@ export const NPM_UPGRADE_ARGS = Object.freeze(["install", "--global", "--ignore-
59
59
 
60
60
  /** The newest published version, or null when the registry did not answer. */
61
61
  export async function latestOnRegistry(name) {
62
+ return (await registryLatest(name)).version
63
+ }
64
+
65
+ /** The registry the newest version is read from, named in doctor's evidence. */
66
+ export const NPM_REGISTRY = "https://registry.npmjs.org"
67
+
68
+ /**
69
+ * The newest published version, with the reason when there is none: the
70
+ * failure code from the one GET call site (network-unavailable,
71
+ * github-unavailable's npm sibling, not-found for an unpublished name), or
72
+ * `unpublished` when the registry answered without a version. `doctor` prints
73
+ * the code; `upgrade` only needs the version.
74
+ *
75
+ * @returns {Promise<{ version: string|null, error: { code: string, message: string }|null }>}
76
+ */
77
+ export async function registryLatest(name) {
62
78
  try {
63
- const meta = await getJson(`https://registry.npmjs.org/${encodeURIComponent(name)}/latest`)
64
- return meta?.version || null
65
- } catch {
66
- return null
79
+ const meta = await getJson(`${NPM_REGISTRY}/${encodeURIComponent(name)}/latest`)
80
+ return meta?.version
81
+ ? { version: meta.version, error: null }
82
+ : { version: null, error: { code: "unpublished", message: "the registry answered without a version" } }
83
+ } catch (error) {
84
+ return { version: null, error: { code: error?.code || "error", message: String(error?.message || error) } }
67
85
  }
68
86
  }
69
87
 
@@ -118,10 +118,11 @@ export const TARGET_NOTE = "<target> is a local Git repository path, or <https u
118
118
  */
119
119
  export const AUTHENTICATION = Object.freeze([
120
120
  "Read-only, and optional. omakit uses your `gh` login if you have one, and",
121
- "otherwise goes unauthenticated. `verify` needs no network; `submit` reads",
122
- "two things online and `--offline` turns both off; `watch` and `parity` are",
123
- `capped at ${UNAUTHENTICATED_LIMIT} requests an hour without a login. omakit never writes a`,
124
- "credential anywhere.",
121
+ "otherwise goes unauthenticated. `verify` needs no network once the pin",
122
+ "exists, except to fetch a reviewer-mode <https url>@<sha> target, once;",
123
+ "`submit` reads two things online and `--offline` turns both off; `watch` and",
124
+ `\`parity\` are capped at ${UNAUTHENTICATED_LIMIT} requests an hour without a login. omakit never`,
125
+ "writes a credential anywhere.",
125
126
  ])
126
127
 
127
128
  /**
@@ -106,12 +106,17 @@ export function validationCommentCommit(comments) {
106
106
  }
107
107
 
108
108
  /**
109
- * @param {{ repoRoot: string, issueUrl: string }} options
109
+ * @param {{ repoRoot: string, issueUrl: string, onPhase?: (name: string) => void,
110
+ * github?: { issue?: typeof issue, issueComments?: typeof issueComments, defaultBranchHead?: typeof defaultBranchHead } }} options
111
+ * `github` is injectable for tests, so the whole path from an issue body to
112
+ * a verdict can be run on data that never left the machine; the default is
113
+ * the read-only GitHub access in `github.mjs`.
110
114
  */
111
- export async function validationWatch({ repoRoot, issueUrl, onPhase }) {
115
+ export async function validationWatch({ repoRoot, issueUrl, onPhase, github = {} }) {
112
116
  // Optional: told the name of the step about to run, so a terminal can say
113
117
  // what is happening while the network answers. Never affects the result.
114
118
  const phase = onPhase || (() => {})
119
+ const read = { issue, issueComments, defaultBranchHead, ...github }
115
120
  const { dir: pinDir } = requirePin(repoRoot)
116
121
  const target = parseIssueUrl(issueUrl)
117
122
  if (`${target.owner}/${target.repository}`.toLowerCase() !== MARKETPLACE_SLUG) {
@@ -124,14 +129,14 @@ export async function validationWatch({ repoRoot, issueUrl, onPhase }) {
124
129
  const record = await loadRecord(pinDir)
125
130
 
126
131
  phase(`reading issue #${target.number}`)
127
- const subject = await issue(target.owner, target.repository, target.number)
132
+ const subject = await read.issue(target.owner, target.repository, target.number)
128
133
  phase(`reading the comments on issue #${target.number}`)
129
- const comments = await issueComments(target.owner, target.repository, target.number)
134
+ const comments = await read.issueComments(target.owner, target.repository, target.number)
130
135
 
131
- const read = await repositoryFor(pinDir, subject)
132
- const repositoryUrl = read.url
133
- const repositoryError = read.error
134
- const issueKind = read.kind
136
+ const repository = await repositoryFor(pinDir, subject)
137
+ const repositoryUrl = repository.url
138
+ const repositoryError = repository.error
139
+ const issueKind = repository.kind
135
140
 
136
141
  let validated = null
137
142
  let baselineError = null
@@ -164,7 +169,7 @@ export async function validationWatch({ repoRoot, issueUrl, onPhase }) {
164
169
  if (repositoryUrl) {
165
170
  phase("reading the plugin repository's default-branch HEAD")
166
171
  try {
167
- head = await defaultBranchHead(repositoryUrl)
172
+ head = await read.defaultBranchHead(repositoryUrl)
168
173
  } catch (error) {
169
174
  headError = { code: error.code || "head-unreadable", message: error.message }
170
175
  }
@@ -202,11 +207,16 @@ export async function validationWatch({ repoRoot, issueUrl, onPhase }) {
202
207
  baselineError,
203
208
  head,
204
209
  headError,
205
- verdict: validationVerdict({ comparable, stale, validated, head, fallback, baselineError, headError, pushedAfterReview }),
210
+ verdict: validationVerdict({ comparable, stale, validated, head, fallback, baselineError, headError, pushedAfterReview, repositoryUrl }),
206
211
  }
207
212
  }
208
213
 
209
- export function validationVerdict({ comparable, stale, validated, head, fallback, baselineError, headError, pushedAfterReview, repositoryUrl = "unknown" }) {
214
+ // `repositoryUrl` has no default on purpose. Measured on 0.1.6: the command
215
+ // computed it and then did not pass it, and the parameter defaulted to the
216
+ // truthy string "unknown", so an issue whose body named no repository was
217
+ // reported as a HEAD that could not be read, and the branch below that names
218
+ // the real cause was reachable from the unit test alone.
219
+ export function validationVerdict({ comparable, stale, validated, head, fallback, baselineError, headError, pushedAfterReview, repositoryUrl }) {
210
220
  if (baselineError) {
211
221
  return {
212
222
  state: "unknown",