omakit 0.1.7 → 0.1.8

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
@@ -231,16 +231,20 @@ the cache. Bumping it changes where the submission contract and the baseline
231
231
  policy are read from, and the procedure in
232
232
  [docs/UPSTREAM_CONTRACT.md](docs/UPSTREAM_CONTRACT.md) ends in re-proving
233
233
  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
234
+ pin is behind in something omakit reads from it, names which paths changed,
235
+ and then leaves it alone: `registry.json` and `site/catalog.json` moving is
236
+ fine, because those are read live from HEAD (about 140 commits a day touch
237
+ only `registry.json`, so "behind" alone would be true of every run); the
238
+ marketplace's code or forms moving is a note, and what you can do about it
239
+ is run `omakit upgrade`, since a newer omakit may already carry the new pin,
240
+ and otherwise open an issue naming the paths. That the
237
241
  pin can go stale unnoticed is the same defect class `omakit watch` reports, so it
238
242
  would be poor form to hide it here.
239
243
 
240
244
  ## Evidence, not claims
241
245
 
242
246
  ```bash
243
- npm test # 147 tests, node --test, no dependencies
247
+ npm test # node --test, no dependencies; green from `git archive` too
244
248
  ```
245
249
 
246
250
  | 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.8",
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. `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. |
@@ -60,6 +61,19 @@ the registry recorded so the corpus always contains repositories that are not
60
61
  side rather than the findings themselves. The GitHub side uses whatever
61
62
  credential `github.mjs` resolves (a `gh` login, and only that), read-only.
62
63
 
64
+ Two rules about the output, stated as rules because each is an exception to
65
+ a wider one. Everything omakit writes itself stays within 80 columns; text
66
+ that will be posted verbatim, the marketplace's own baseline report and the
67
+ issue body rendered from the pinned form, is never wrapped and may exceed 80,
68
+ because a wrapped body would not be the body. And stdout is the whole result;
69
+ stderr carries interactive decoration, the progress line from `progress.mjs`
70
+ and the chooser from `ask.mjs`, only when stderr is a TTY, so a piped stderr
71
+ is empty on success, and failures go to stderr always. `tests/unit/cli.test.mjs`
72
+ holds both: the width test names the two verbatim regions by their heading
73
+ and holds every other line to 80, the three-way test asserts the empty pipe,
74
+ and a pseudo-terminal test asserts that a terminal's stderr gets the progress
75
+ sequences and nothing else.
76
+
63
77
  `parity`, `watch`, `doctor` and `submit` reach the network, with Node's
64
78
  built-in `fetch`, which does not read proxy environment variables by default.
65
79
  Behind a proxy, run them with `NODE_USE_ENV_PROXY=1`. `submit` reads two things
@@ -30,7 +30,6 @@ 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
32
  import { omakitCacheDir } from "./paths.mjs"
33
- import { parityOutput } from "./parity-output.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)
@@ -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
45
  import { latestOnRegistry, 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 latestOnRegistry }} 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 = latestOnRegistry }) {
139
182
  // Optional: told what is being read while the network answers. Never
140
183
  // affects the result.
141
184
  const phase = onPhase || (() => {})
@@ -185,13 +228,13 @@ export async function doctor({ repoRoot, offline = false, onPhase, env = process
185
228
  if (!offline && identity) {
186
229
  phase("reading the marketplace's current default-branch HEAD")
187
230
  try {
188
- const head = await defaultBranchHead(MARKETPLACE_PIN.repository)
231
+ const head = await resolveHead(MARKETPLACE_PIN.repository)
189
232
  let changedPaths = []
190
233
  if (head.commit !== identity.commit) {
191
234
  phase("comparing each path omakit reads between the pin and HEAD")
192
235
  changedPaths = await changedPinPaths({ pinDir: dir, headCommit: head.commit, pinCommit: identity.commit })
193
236
  }
194
- checks.push(pinFreshness(identity, head, changedPaths))
237
+ checks.push(pinFreshness(identity, head, changedPaths, { issues: self.repository }))
195
238
  } catch (error) {
196
239
  add("pin.freshness", "unknown", `could not read the marketplace's HEAD (${error.code || "error"})`,
197
240
  error.code === "network-unavailable" ? "Connect to the network, or pass --offline to skip the two checks that need it." : null,
@@ -199,7 +242,7 @@ export async function doctor({ repoRoot, offline = false, onPhase, env = process
199
242
  }
200
243
 
201
244
  phase("asking the npm registry for the newest published version")
202
- const latest = await latestOnRegistry(self.name)
245
+ const latest = await latestVersion(self.name)
203
246
  if (latest) {
204
247
  const current = latest === self.version
205
248
  add("omakit.latest", current ? "ok" : "advice",
@@ -23,10 +23,11 @@ import {
23
23
 
24
24
  const body = " ".repeat(GUTTER)
25
25
 
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. */
26
+ /** 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
27
  function stateOf(check) {
28
28
  if (check.verdict === "pass") return "pass"
29
29
  if (check.verdict === "unknown") return "unknown"
30
+ if (check.verdict === "skipped") return "skipped"
30
31
  return check.severity === "advisory" ? "advisory" : "fail"
31
32
  }
32
33
 
@@ -101,10 +102,10 @@ export function renderSubmit(result, { colour = colourEnabled() } = {}) {
101
102
  out.push(...field("marketplace", `${result.pin.commit}, baseline ${result.pin.baselineVersion}, ${result.pin.enforcementMode}`, c))
102
103
  out.push("")
103
104
 
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"
105
+ // Passing checks run together, and so do a check that waited on another
106
+ // and one a flag skipped: all are two quiet lines. Anything else gets a
107
+ // blank line on each side, collapsed where two blocks meet.
108
+ const quiet = (state) => state === "pass" || state === "unknown" || state === "skipped"
108
109
  let previous = "pass"
109
110
  for (const [index, check] of result.checks.entries()) {
110
111
  const state = stateOf(check)
@@ -168,7 +169,12 @@ export function renderSubmit(result, { colour = colourEnabled() } = {}) {
168
169
  const validation = result.validationCommit.defaultBranchHead
169
170
  ? `${result.validationCommit.local} is the ${result.validationCommit.branch || "default"}-branch HEAD, so it is the commit the marketplace will validate.`
170
171
  : `${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))
172
+ // A skipped check is said on the READY line itself, so the one line an
173
+ // agent quotes does not read as "everything passed" when one check never
174
+ // ran. The count is the whole `skipped` list; today only --offline skips.
175
+ const skipped = (result.skipped || []).length
176
+ const skippedNote = skipped ? ` ${skipped === 1 ? "1 check" : `${skipped} checks`} skipped (--offline).` : ""
177
+ out.push(...verdict("pass", "READY", `every blocking check passed.${skippedNote} ${validation}`, c))
172
178
  out.push("")
173
179
  out.push(...section("issue title", c))
174
180
  out.push(c("heading", result.issue.title))
@@ -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
  ? {
@@ -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",