omakit 0.1.2 → 0.1.4

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.
@@ -1,8 +1,7 @@
1
- // The plugin-id and repository universe, read from the pinned marketplace
2
- // checkout. Like the submission contract, nothing here is hardcoded: the
3
- // reserved namespace is read out of the marketplace's own catalog builder, the
4
- // listed ids out of the published catalog and the registry sources, and the
5
- // retired ids out of `registry.json`.
1
+ // The plugin-id and repository universe. Like the submission contract, nothing
2
+ // here is hardcoded: the reserved namespace is read out of the marketplace's
3
+ // own catalog builder, the listed ids out of the published catalog and the
4
+ // registry sources, and the retired ids out of `registry.json`.
6
5
  //
7
6
  // Measured reason these three checks exist (docs/MEASUREMENTS.md M2): the
8
7
  // marketplace refuses a submission whose id is already listed
@@ -13,15 +12,31 @@
13
12
  // median submission-to-publication time rose sevenfold between 2026-W33 and
14
13
  // 2026-W36, and it is knowable before submitting from data the marketplace
15
14
  // publishes.
15
+ //
16
+ // Two kinds of file, two sources (docs/MEASUREMENTS.md M7). The catalog
17
+ // builder is code, and code is only ever read from the pin: it moves a few
18
+ // times a month and must never be fetched and executed unreviewed. The
19
+ // registry and the catalog are data, and the pin's copy of them is stale
20
+ // within hours: registry.json changed in 4,201 of the marketplace's 4,293
21
+ // commits in the 30 days to 2026-09-13, about 140 a day. So those two files
22
+ // are read from the marketplace's current default-branch HEAD when the network
23
+ // is there, at the exact commit `defaultBranchHead()` resolved so they cannot
24
+ // disagree with each other, and from the pin when it is not or when the caller
25
+ // asks for --offline. Every result says which, with the commit.
16
26
 
17
- import { readFileSync } from "node:fs"
18
- import { join } from "node:path"
19
- import { requirePin } from "./pin.mjs"
27
+ import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
28
+ import { dirname, join } from "node:path"
29
+ import { MARKETPLACE_PIN, requirePin } from "./pin.mjs"
30
+ import { defaultBranchHead, getJson } from "./github.mjs"
31
+ import { omakitCacheDir } from "./paths.mjs"
20
32
 
21
33
  export const CATALOG_PATH = "site/catalog.json"
22
34
  export const REGISTRY_PATH = "registry.json"
23
35
  export const CATALOG_BUILDER_PATH = "scripts/build-catalog.mjs"
24
36
 
37
+ /** The only two marketplace files ever read from HEAD. Everything else comes from the pin. */
38
+ export const LIVE_PATHS = Object.freeze([REGISTRY_PATH, CATALOG_PATH])
39
+
25
40
  export class RegistryError extends Error {
26
41
  constructor(code, message) {
27
42
  super(message)
@@ -65,18 +80,181 @@ function repositorySlug(value) {
65
80
  }
66
81
 
67
82
  /**
68
- * @param {{ repoRoot?: string, pinDir?: string }} [options]
83
+ * The one URL shape a live registry file is read from: one of LIVE_PATHS at
84
+ * one explicit 40-character commit on the marketplace's raw file host. Never
85
+ * a branch name, so the two files always come from the same commit and the
86
+ * commit named in the output is the one they came from; never a path outside
87
+ * LIVE_PATHS, so nothing executable can arrive this way.
88
+ */
89
+ export function liveFileUrl(commit, path) {
90
+ if (!/^[0-9a-f]{40}$/.test(String(commit))) {
91
+ throw new RegistryError("usage", `a live registry file is read at a 40-character commit, not "${commit}"`)
92
+ }
93
+ if (!LIVE_PATHS.includes(path)) {
94
+ throw new RegistryError("usage", `${path} is never read from HEAD; only ${LIVE_PATHS.join(" and ")} are`)
95
+ }
96
+ const raw = MARKETPLACE_PIN.repository.replace(/^https:\/\/github\.com\//, "https://raw.githubusercontent.com/")
97
+ return `${raw}/${commit}/${path}`
98
+ }
99
+
100
+ /** Where the live files for one commit are kept: beside the pin, never inside it. */
101
+ export function liveCacheDir(commit, cacheRoot = omakitCacheDir("registry")) {
102
+ return join(cacheRoot, commit)
103
+ }
104
+
105
+ /** A cached commit is one whose meta.json, written last, names every file present. */
106
+ function readCached(liveCache) {
107
+ try {
108
+ const meta = JSON.parse(readFileSync(join(liveCache, "meta.json"), "utf8"))
109
+ if (typeof meta.fetchedAt !== "string" || JSON.stringify(meta.paths) !== JSON.stringify(LIVE_PATHS)) return null
110
+ const files = {}
111
+ for (const path of LIVE_PATHS) files[path] = JSON.parse(readFileSync(join(liveCache, path), "utf8"))
112
+ return { fetchedAt: meta.fetchedAt, files }
113
+ } catch {
114
+ return null
115
+ }
116
+ }
117
+
118
+ /**
119
+ * One directory per commit, the files first and meta.json last, so a run that
120
+ * dies mid-write leaves a directory readCached() does not accept. Older
121
+ * commits are removed: at about 140 registry commits a day, keeping every one
122
+ * would grow the cache by the measured 13 MB per run.
123
+ */
124
+ function writeCached(cacheRoot, commit, fetchedAt, files) {
125
+ const liveCache = liveCacheDir(commit, cacheRoot)
126
+ rmSync(liveCache, { recursive: true, force: true })
127
+ for (const path of LIVE_PATHS) {
128
+ mkdirSync(dirname(join(liveCache, path)), { recursive: true })
129
+ writeFileSync(join(liveCache, path), JSON.stringify(files[path]))
130
+ }
131
+ writeFileSync(join(liveCache, "meta.json"), `${JSON.stringify({ commit, fetchedAt, paths: LIVE_PATHS }, null, 2)}\n`)
132
+ for (const entry of readdirSync(cacheRoot, { withFileTypes: true })) {
133
+ if (entry.isDirectory() && entry.name !== commit) rmSync(join(cacheRoot, entry.name), { recursive: true, force: true })
134
+ }
135
+ }
136
+
137
+ /**
138
+ * The registry and the catalog, from the marketplace's current default-branch
139
+ * HEAD when the network is there and from the pin when it is not. Never
140
+ * throws for a network reason: a HEAD that cannot be read falls back to the pin
141
+ * and says so in `reason`. The pinned checkout is never written to; a fetched
142
+ * pair is cached under `cacheRoot/<commit>/`.
143
+ *
144
+ * `resolveHead`, `fetchJson`, `cacheRoot` and `now` are injectable for tests;
145
+ * the defaults are the tool's one HEAD resolver and its one GET call site.
146
+ *
147
+ * @param {{ repoRoot?: string, pinDir?: string, offline?: boolean,
148
+ * resolveHead?: (url: string) => Promise<{ commit: string }>,
149
+ * fetchJson?: (url: string) => Promise<object>, cacheRoot?: string,
150
+ * now?: () => string }} [options]
151
+ * @returns {Promise<{ source: "head"|"pin", commit: string, fetchedAt: string|null,
152
+ * reason: string|null, fallback: { what: string, code: string, message: string }|null,
153
+ * registry: object, catalog: object }>}
154
+ * `reason` is the whole story, for `--json`; `fallback` is its parts, for
155
+ * the one line a person reads (registrySourceDetail).
156
+ */
157
+ export async function liveRegistry(options = {}) {
158
+ const pinDir = options.pinDir || requirePin(options.repoRoot).dir
159
+ const resolveHead = options.resolveHead || defaultBranchHead
160
+ const fetchJson = options.fetchJson || getJson
161
+ const cacheRoot = options.cacheRoot || omakitCacheDir("registry")
162
+ const now = options.now || (() => new Date().toISOString())
163
+ const fromPin = (reason, fallback = null) => ({
164
+ source: "pin",
165
+ commit: MARKETPLACE_PIN.commit,
166
+ fetchedAt: null,
167
+ reason,
168
+ fallback,
169
+ registry: readJson(pinDir, REGISTRY_PATH),
170
+ catalog: readJson(pinDir, CATALOG_PATH),
171
+ })
172
+ const failed = (what, error) => {
173
+ const code = error?.code || "error"
174
+ const message = String(error?.message || error)
175
+ return fromPin(`${what} (${code}): ${message}`, { what, code, message })
176
+ }
177
+ if (options.offline) return fromPin("--offline")
178
+
179
+ let commit
180
+ try {
181
+ commit = String((await resolveHead(MARKETPLACE_PIN.repository)).commit).toLowerCase()
182
+ } catch (error) {
183
+ return failed("HEAD unreadable", error)
184
+ }
185
+ if (commit === MARKETPLACE_PIN.commit) {
186
+ // HEAD is the pin, so the pin's files are HEAD's files: nothing to fetch.
187
+ return { ...fromPin(null), source: "head", fetchedAt: now() }
188
+ }
189
+
190
+ const cached = readCached(liveCacheDir(commit, cacheRoot))
191
+ if (cached) {
192
+ return { source: "head", commit, fetchedAt: cached.fetchedAt, reason: null, fallback: null, registry: cached.files[REGISTRY_PATH], catalog: cached.files[CATALOG_PATH] }
193
+ }
194
+
195
+ const files = {}
196
+ try {
197
+ for (const path of LIVE_PATHS) files[path] = await fetchJson(liveFileUrl(commit, path))
198
+ } catch (error) {
199
+ return failed(`registry at ${commit} unreadable`, error)
200
+ }
201
+ const fetchedAt = now()
202
+ try {
203
+ mkdirSync(cacheRoot, { recursive: true })
204
+ writeCached(cacheRoot, commit, fetchedAt, files)
205
+ } catch {
206
+ // A cache that cannot be written costs the next run a refetch, nothing else.
207
+ }
208
+ return { source: "head", commit, fetchedAt, reason: null, fallback: null, registry: files[REGISTRY_PATH], catalog: files[CATALOG_PATH] }
209
+ }
210
+
211
+ /**
212
+ * How a check names where its registry data came from. Short hash for the
213
+ * pin, which the docs name that way; the full commit for HEAD, which nothing
214
+ * else names.
215
+ *
216
+ * A fallback is one clause, not the whole story: the transport error's own
217
+ * parenthetical and its "while reading <path>" tail are dropped and the
218
+ * failure code takes their place, so the person reads
219
+ * "registry at the pin 38060f89; HEAD unreadable: github.com did not answer
220
+ * (network-unavailable)" on one line. Measured before this: the full text
221
+ * nested three sets of parentheses and wrapped to three lines at 80 columns.
222
+ * `--json` keeps the whole text under `registry.reason`.
223
+ */
224
+ export function registrySourceDetail(live) {
225
+ if (live.source === "head") return `registry at ${live.commit}, read ${live.fetchedAt}`
226
+ const pin = `registry at the pin ${live.commit.slice(0, 8)}`
227
+ if (!live.fallback) return live.reason === "--offline" ? `${pin} (offline)` : pin
228
+ const { what, code, message } = live.fallback
229
+ const clause = message.replace(/ while reading .*$/, "").replace(/\s*\([^()]*\)\s*$/, "").trim()
230
+ const short = what.replace(/^registry at ([0-9a-f]{40}) unreadable$/, (_, sha) => `HEAD ${sha.slice(0, 7)} unreadable`)
231
+ return `${pin}; ${short}: ${clause} (${code})`
232
+ }
233
+
234
+ /**
235
+ * @param {{ repoRoot?: string, pinDir?: string, registry?: object, catalog?: object }} [options]
236
+ * `registry` and `catalog` are the parsed files from liveRegistry(); without
237
+ * them the pin's copies are read.
69
238
  * @returns {{ reservedPrefix: string, listedIds: Set<string>, retiredIds: Set<string>,
70
- * listedRepositories: Set<string>, counts: object }}
239
+ * listedRepositories: Set<string>, listedBy: Map<string, string>, counts: object }}
240
+ * `listedBy` names the repository slug that lists each id, where the
241
+ * registry or catalog says which, so a taken id can be blamed on a
242
+ * repository and told apart from the subject's own listing.
71
243
  */
72
244
  export function idUniverse(options = {}) {
73
245
  const pinDir = options.pinDir || requirePin(options.repoRoot).dir
74
- const catalog = readJson(pinDir, CATALOG_PATH)
75
- const registry = readJson(pinDir, REGISTRY_PATH)
246
+ const catalog = options.catalog || readJson(pinDir, CATALOG_PATH)
247
+ const registry = options.registry || readJson(pinDir, REGISTRY_PATH)
76
248
 
77
249
  const listedIds = new Set()
250
+ const listedBy = new Map()
251
+ const listed = (id, repo) => {
252
+ listedIds.add(id)
253
+ const slug = repositorySlug(repo)
254
+ if (slug && !listedBy.has(id)) listedBy.set(id, slug)
255
+ }
78
256
  for (const plugin of Array.isArray(catalog.plugins) ? catalog.plugins : []) {
79
- if (typeof plugin?.id === "string" && plugin.id) listedIds.add(plugin.id)
257
+ if (typeof plugin?.id === "string" && plugin.id) listed(plugin.id, plugin.repo)
80
258
  }
81
259
  const catalogIds = listedIds.size
82
260
 
@@ -86,17 +264,17 @@ export function idUniverse(options = {}) {
86
264
  const slug = repositorySlug(source?.repo)
87
265
  if (slug) listedRepositories.add(slug)
88
266
  if (source?.plugins && typeof source.plugins === "object" && !Array.isArray(source.plugins)) {
89
- for (const id of Object.keys(source.plugins)) listedIds.add(id)
267
+ for (const id of Object.keys(source.plugins)) listed(id, source.repo)
90
268
  }
91
269
  if (Array.isArray(source?.plugins)) {
92
270
  for (const entry of source.plugins) {
93
271
  const id = typeof entry === "string" ? entry : entry?.id
94
- if (id) listedIds.add(id)
272
+ if (id) listed(id, source.repo)
95
273
  }
96
274
  }
97
- if (typeof source?.catalog?.id === "string") listedIds.add(source.catalog.id)
275
+ if (typeof source?.catalog?.id === "string") listed(source.catalog.id, source.repo)
98
276
  for (const id of source?.automatedSecurityBaseline?.pluginIds || []) {
99
- if (typeof id === "string") listedIds.add(id)
277
+ if (typeof id === "string") listed(id, source.repo)
100
278
  }
101
279
  }
102
280
 
@@ -112,6 +290,7 @@ export function idUniverse(options = {}) {
112
290
  listedIds,
113
291
  retiredIds,
114
292
  listedRepositories,
293
+ listedBy,
115
294
  counts: {
116
295
  catalogPlugins: catalogIds,
117
296
  registrySources: sources.length,
@@ -185,7 +364,11 @@ export function figure(n) {
185
364
 
186
365
  /**
187
366
  * @param {{ id: string, repositoryUrl?: string|null }} subject
188
- * @returns {{ ok: boolean, problems: Array<{ code: string, detail: string }> }}
367
+ * @returns {{ ok: boolean, problems: Array<{ code: string, detail: string, repository?: string|null, sameRepository?: boolean }> }}
368
+ * A `plugin-id-listed` problem names the repository that lists the id
369
+ * (`repository`, a slug, or null when the registry does not say) and
370
+ * whether that is the subject's own (`sameRepository`), because the two
371
+ * have different remedies: another id, or nothing to submit at all.
189
372
  */
190
373
  export function checkIdentity(universe, subject) {
191
374
  const problems = []
@@ -194,6 +377,7 @@ export function checkIdentity(universe, subject) {
194
377
  problems.push({ code: "plugin-id-missing", detail: "the root manifest declares no id" })
195
378
  return { ok: false, problems }
196
379
  }
380
+ const slug = repositorySlug(subject.repositoryUrl)
197
381
  if (id.toLowerCase().startsWith(universe.reservedPrefix)) {
198
382
  problems.push({
199
383
  code: "reserved-plugin-id",
@@ -204,11 +388,17 @@ export function checkIdentity(universe, subject) {
204
388
  problems.push({ code: "plugin-id-retired", detail: `"${id}" was used by a previous listing (registry.json retiredPluginIds)` })
205
389
  }
206
390
  if (universe.listedIds.has(id)) {
207
- problems.push({ code: "plugin-id-listed", detail: `"${id}" is already listed` })
391
+ const repository = universe.listedBy?.get(id) || null
392
+ const sameRepository = Boolean(repository && slug && repository === slug)
393
+ problems.push({
394
+ code: "plugin-id-listed",
395
+ detail: `"${id}" is already listed${repository ? ` by ${repository}` : ""}`,
396
+ repository,
397
+ sameRepository,
398
+ })
208
399
  }
209
- const slug = repositorySlug(subject.repositoryUrl)
210
400
  if (slug && universe.listedRepositories.has(slug)) {
211
- problems.push({ code: "submission-repository-listed", detail: `${slug} is already listed` })
401
+ problems.push({ code: "submission-repository-listed", detail: `${slug} is already listed`, repository: slug, sameRepository: true })
212
402
  }
213
403
  return { ok: problems.length === 0, problems }
214
404
  }
@@ -23,9 +23,10 @@ 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. */
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
27
  function stateOf(check) {
28
28
  if (check.verdict === "pass") return "pass"
29
+ if (check.verdict === "unknown") return "unknown"
29
30
  return check.severity === "advisory" ? "advisory" : "fail"
30
31
  }
31
32
 
@@ -53,7 +54,7 @@ function checkBlock(check, c) {
53
54
  ? `${body}${c("fail", "-")} ${c("placeholder", line.trimStart().slice(2))}`
54
55
  : `${" ".repeat(GUTTER + STEP)}${c("placeholder", line.trimStart())}`)))
55
56
  }
56
- if (check.remedy) out.push(...action(check.remedy, c))
57
+ for (const remedy of [].concat(check.remedy || [])) out.push(...action(remedy, c))
57
58
  // The measured reason is the point of the check, so it is not dimmed: only
58
59
  // its label is grey, and on a low-contrast theme the number still reads.
59
60
  out.push(...labelled("why", check.why, c))
@@ -69,12 +70,14 @@ export function renderSubmit(result, { colour = colourEnabled() } = {}) {
69
70
  out.push(...field("marketplace", `${result.pin.commit}, baseline ${result.pin.baselineVersion}, ${result.pin.enforcementMode}`, c))
70
71
  out.push("")
71
72
 
72
- // Passing checks run together; anything else gets a blank line on each side,
73
+ // Passing checks run together, and so does a check that waited on another:
74
+ // both are two quiet lines. Anything else gets a blank line on each side,
73
75
  // collapsed where two blocks meet.
76
+ const quiet = (state) => state === "pass" || state === "unknown"
74
77
  let previous = "pass"
75
78
  for (const [index, check] of result.checks.entries()) {
76
79
  const state = stateOf(check)
77
- if (index > 0 && (state !== "pass" || previous !== "pass")) out.push("")
80
+ if (index > 0 && (!quiet(state) || !quiet(previous))) out.push("")
78
81
  out.push(...checkBlock(check, c))
79
82
  previous = state
80
83
  }
@@ -89,13 +92,17 @@ export function renderSubmit(result, { colour = colourEnabled() } = {}) {
89
92
  }
90
93
 
91
94
  if (!result.ready) {
95
+ // Root causes only: a check that waited on a failed one is not listed,
96
+ // and the count says how many waited.
92
97
  const failed = result.checks.filter((check) => result.blocking.includes(check.id))
98
+ const waited = result.checks.filter((check) => check.verdict === "unknown").length
93
99
  const count = failed.length === 1 ? "1 blocking check" : `${failed.length} blocking checks`
94
- out.push(...verdict("fail", "REFUSED", `${count} failed, so no submission body is produced.`, c))
100
+ const waiting = waited ? ` ${waited === 1 ? "1 check" : `${waited} checks`} could not run until ${failed.length === 1 ? "it passes" : "they pass"}.` : ""
101
+ out.push(...verdict("fail", "REFUSED", `${count} failed, so no submission body is produced.${waiting}`, c))
95
102
  out.push("")
96
103
  for (const check of failed) {
97
104
  out.push(`${body}${c("name", check.id)}`)
98
- if (check.remedy) out.push(...action(check.remedy, c))
105
+ if (check.remedy) for (const remedy of [].concat(check.remedy)) out.push(...action(remedy, c))
99
106
  else out.push(...wrap(check.detail, { indent: GUTTER }, c))
100
107
  out.push("")
101
108
  }
@@ -11,8 +11,6 @@
11
11
  // and stops, which is the same contract every other command here keeps.
12
12
 
13
13
  import { execFileSync } from "node:child_process"
14
- import { existsSync } from "node:fs"
15
- import { delimiter, join } from "node:path"
16
14
  import { banner } from "./banner.mjs"
17
15
  import { credential, UNAUTHENTICATED_LIMIT } from "./github.mjs"
18
16
  import { ensurePin, marketplacePinDir, pinDiskUsage } from "./pin.mjs"
@@ -21,6 +19,7 @@ import { action, colourEnabled, GUTTER, mark, styler, wrap } from "./style.mjs"
21
19
  import { TAGLINE } from "./usage.mjs"
22
20
  import { installCompletion } from "./completion.mjs"
23
21
  import { submissionContract } from "./form.mjs"
22
+ import { pathHint } from "./path-hint.mjs"
24
23
 
25
24
  function version(command) {
26
25
  try {
@@ -32,11 +31,6 @@ function version(command) {
32
31
  }
33
32
  }
34
33
 
35
- /** Is `omakit` reachable as a bare command, without asking a shell? */
36
- export function onPath(name = "omakit", env = process.env) {
37
- return (env.PATH || "").split(delimiter).some((dir) => dir && existsSync(join(dir, name)))
38
- }
39
-
40
34
  /**
41
35
  * @param {{ repoRoot: string, entryPoint: string, stream?: NodeJS.WriteStream }} options
42
36
  */
@@ -101,9 +95,14 @@ export async function setup({ repoRoot, entryPoint, stream = process.stdout }) {
101
95
  for (const line of wrap("Every rule omakit checks is read from that checkout, at that exact commit. It never moves on its own. `omakit doctor` says when it is behind.", {}, c)) out(line)
102
96
  out()
103
97
 
104
- if (!onPath()) {
105
- step("info", "`omakit` is not on your PATH yet. This puts it there:")
106
- fix(`ln -s ${entryPoint} ~/.local/bin/omakit`)
98
+ // The hint is for the install that is here: a symlink for a clone, the npm
99
+ // prefix's bin on PATH for a package, in the shell in $SHELL (path-hint.mjs).
100
+ const reach = pathHint({ repoRoot, entryPoint })
101
+ if (!reach.reachable) {
102
+ step("info", reach.line
103
+ ? `${reach.reason} This puts it there${reach.where ? `; keep it in ${reach.where}` : ""}:`
104
+ : reach.reason)
105
+ if (reach.line) fix(reach.line)
107
106
  out()
108
107
  }
109
108
 
@@ -14,7 +14,7 @@
14
14
  import { resolveSubject, SubjectError } from "../subject/resolve.mjs"
15
15
  import { requirePin } from "./pin.mjs"
16
16
  import { submissionContract, resolveCategory, resolveTags } from "./form.mjs"
17
- import { idUniverse, checkIdentity, baselineFigures, figure } from "./registry.mjs"
17
+ import { idUniverse, checkIdentity, baselineFigures, figure, liveRegistry, registrySourceDetail } from "./registry.mjs"
18
18
  import { readTree } from "./tree.mjs"
19
19
  import { inspectTree } from "./plugin.mjs"
20
20
  import { findAgentControl, REMEDY as AGENT_CONTROL_REMEDY } from "./agent-control.mjs"
@@ -23,6 +23,39 @@ import { renderIssue, verifyAgainstOfficialParser } from "./issue.mjs"
23
23
  import { defaultBranchHead } from "./github.mjs"
24
24
  import { REFRESH_ACTION } from "./watch.mjs"
25
25
  import { omakitCacheDir } from "./paths.mjs"
26
+ import { join } from "node:path"
27
+ import { pathToFileURL } from "node:url"
28
+
29
+ /**
30
+ * The marketplace's own name for the verification action that lists a newer
31
+ * commit of an already listed plugin, read from the pin rather than typed:
32
+ * a form option retyped here would drift by a word, and a person would be
33
+ * sent to choose something the form no longer offers.
34
+ */
35
+ async function newerCommitAction(pinDir) {
36
+ const verification = await import(pathToFileURL(join(pinDir, "scripts/plugin-verification-request.mjs")).href)
37
+ return verification.upstreamUpdateVerificationAction
38
+ }
39
+
40
+ /**
41
+ * One arrow per cause, in this order, so a person fixes the thing that is
42
+ * actually wrong. Measured before this: an id listed by its own repository
43
+ * was told to "choose an unused plugin id outside the reserved namespace",
44
+ * which is the remedy for a different failure, and an author who followed
45
+ * it would have renamed a plugin the marketplace already lists.
46
+ */
47
+ function identityRemedies(identity, universe, newerCommit) {
48
+ const remedies = []
49
+ const codes = new Set(identity.problems.map((problem) => problem.code))
50
+ if (codes.has("reserved-plugin-id")) remedies.push(`Choose a plugin id outside the reserved ${universe.reservedPrefix}* namespace.`)
51
+ if (codes.has("plugin-id-retired")) remedies.push("That id was retired by the marketplace and cannot be reused; choose another.")
52
+ const taken = identity.problems.find((problem) => problem.code === "plugin-id-listed" && !problem.sameRepository)
53
+ if (taken) remedies.push(taken.repository ? `That id is taken by ${taken.repository}; choose another.` : "That id is already listed; choose another.")
54
+ if (identity.problems.some((problem) => problem.sameRepository)) {
55
+ remedies.push(`This plugin is already listed, so there is nothing to submit. To get a newer commit listed, use the marketplace's verification form and choose "${newerCommit}"; \`omakit watch <the submission issue>\` shows which commit is listed now.`)
56
+ }
57
+ return remedies
58
+ }
26
59
 
27
60
  export class SubmitError extends Error {
28
61
  constructor(code, message) {
@@ -32,23 +65,56 @@ export class SubmitError extends Error {
32
65
  }
33
66
  }
34
67
 
68
+ /**
69
+ * A check has three verdicts. `pass` and `fail` are its own. `unknown` is
70
+ * for a check that could not run because one it depends on failed: it is
71
+ * rendered as a question, its detail names what it waited on, and it counts
72
+ * in neither `blocking` nor `advisory`, so a refusal lists root causes only.
73
+ * Measured before this: a run with no --category and no --tags on a listed
74
+ * plugin said "6 blocking checks failed" for two causes, because headings,
75
+ * checklist and official-parser each failed for want of a body nobody could
76
+ * render yet, and the closing refusal listed them with "not rendered" where
77
+ * a remedy goes.
78
+ */
35
79
  function check(id, fields) {
80
+ const waitedOn = (fields.waitedOn || []).filter(Boolean)
36
81
  return {
37
82
  id,
38
83
  source: fields.source,
39
84
  why: fields.why,
40
85
  severity: fields.severity || "blocking",
41
- verdict: fields.verdict ? "pass" : "fail",
42
- detail: fields.detail || "",
86
+ verdict: waitedOn.length ? "unknown" : fields.verdict ? "pass" : "fail",
87
+ detail: waitedOn.length ? `not checked: it needs ${waitedOn.join(" and ")} to pass first` : fields.detail || "",
43
88
  paths: fields.paths || [],
44
- remedy: fields.remedy || null,
89
+ // One arrow, or one per cause in the order they should be read.
90
+ remedy: waitedOn.length ? null : Array.isArray(fields.remedy) ? (fields.remedy.length ? fields.remedy : null) : fields.remedy || null,
45
91
  }
46
92
  }
47
93
 
94
+ /**
95
+ * What `submit` needs on the command line, decided before any check runs:
96
+ * the category and the tags are editorial choices nobody else can make, and
97
+ * a run without them has nothing to render. The controlled values come from
98
+ * the form at the pin, so the usage message lists exactly what the form
99
+ * accepts. Null when nothing is missing.
100
+ *
101
+ * @returns {{ missing: string[], categories: string[], tags: string[], maximumTags: number }|null}
102
+ */
103
+ export function missingSubmitFlags(contract, { category, tags } = {}) {
104
+ const missing = []
105
+ if (!String(category ?? "").trim()) missing.push("--category")
106
+ if (!(Array.isArray(tags) ? tags : String(tags ?? "").split(",")).some((value) => String(value).trim())) missing.push("--tags")
107
+ if (!missing.length) return null
108
+ return { missing, categories: [...contract.categories], tags: [...contract.tagLabels], maximumTags: contract.maximumTags }
109
+ }
110
+
48
111
  /**
49
112
  * @param {{ repoRoot: string, target: string, category?: string, tags?: string|string[],
50
113
  * notes?: string, suggestedTag?: string, pluginName?: string,
51
- * allowDirty?: boolean, offline?: boolean }} options
114
+ * allowDirty?: boolean, offline?: boolean,
115
+ * readRegistry?: typeof liveRegistry }} options
116
+ * `readRegistry` is injectable for tests; the default reads the marketplace's
117
+ * current HEAD, or the pin with `offline`.
52
118
  */
53
119
  export async function submitPreflight(options) {
54
120
  const { repoRoot } = options
@@ -57,11 +123,14 @@ export async function submitPreflight(options) {
57
123
  const phase = options.onPhase || (() => {})
58
124
 
59
125
  phase("verifying the pinned marketplace checkout")
60
- const { identity: pinIdentity } = requirePin(repoRoot)
126
+ const { dir: pinDir, identity: pinIdentity } = requirePin(repoRoot)
61
127
  phase("reading the submission contract from the pin")
62
128
  const contract = await submissionContract({ repoRoot })
63
- phase("reading the listed and retired plugin ids")
64
- const universe = idUniverse({ repoRoot })
129
+ phase(options.offline ? "reading the listed and retired plugin ids from the pin" : "reading the marketplace's current registry")
130
+ const live = await (options.readRegistry || liveRegistry)({ repoRoot, offline: options.offline === true })
131
+ const universe = idUniverse({ repoRoot, registry: live.registry, catalog: live.catalog })
132
+ // The documented figures are the pin's by design: they are cited in prose
133
+ // that a test holds to the pin, so they never move between two runs.
65
134
  const figures = baselineFigures({ repoRoot })
66
135
 
67
136
  phase("resolving the subject commit")
@@ -143,14 +212,15 @@ export async function submitPreflight(options) {
143
212
  // --- identity -------------------------------------------------------------
144
213
 
145
214
  const identity = checkIdentity(universe, { id: tree.pluginId, repositoryUrl: subject.repository.url })
215
+ const identityRemedy = identity.ok ? null : identityRemedies(identity, universe, await newerCommitAction(pinDir))
146
216
  checks.push(check("identity.available", {
147
217
  source: "marketplace-pin",
148
- why: `The marketplace refuses \`plugin-id-listed\`, \`plugin-id-retired\`, \`reserved-plugin-id\` and \`submission-repository-listed\`. Checked here against ${universe.counts.listedIds} listed ids, ${universe.counts.retiredIds} retired ids and ${universe.counts.listedRepositories} listed repositories read from the pinned registry and catalog.`,
218
+ why: `The marketplace refuses \`plugin-id-listed\`, \`plugin-id-retired\`, \`reserved-plugin-id\` and \`submission-repository-listed\`. Checked here against ${figure(universe.counts.listedIds)} listed ids, ${figure(universe.counts.retiredIds)} retired ids and ${figure(universe.counts.listedRepositories)} listed repositories from the registry and catalog at the commit the detail names, and the reserved namespace from the pinned catalog builder. The registry is read from the marketplace's current HEAD when the network is there because the pin's copy is stale within hours: registry.json changed in 4,201 of the marketplace's 4,293 commits in the 30 days to 2026-09-13, about 140 a day (docs/MEASUREMENTS.md M7). Code and the form are only ever read from the pin.`,
149
219
  verdict: identity.ok,
150
- detail: identity.ok
220
+ detail: `${identity.ok
151
221
  ? `id "${tree.pluginId}" is unused, outside the reserved ${universe.reservedPrefix}* namespace, and the repository is not listed`
152
- : identity.problems.map((problem) => `${problem.code}: ${problem.detail}`).join("; "),
153
- remedy: identity.ok ? null : "Choose an unused plugin id outside the reserved namespace.",
222
+ : identity.problems.map((problem) => `${problem.code}: ${problem.detail}`).join("; ")}; ${registrySourceDetail(live)}`,
223
+ remedy: identityRemedy,
154
224
  }))
155
225
 
156
226
  // --- the submission itself ------------------------------------------------
@@ -185,9 +255,17 @@ export async function submitPreflight(options) {
185
255
  remedy: tags.ok ? null : "Pass --tags with 1 to 3 comma-separated values from the list.",
186
256
  }))
187
257
 
258
+ // The body needs every field above and the repository URL below; the three
259
+ // checks that read it wait on whichever of those failed.
260
+ const bodyWaitsOn = [
261
+ !pluginName && "submission.title",
262
+ !category.ok && "submission.category",
263
+ !tags.ok && "submission.tags",
264
+ !subject.repository.url && "submission.repository-url",
265
+ ].filter(Boolean)
188
266
  let issue = null
189
267
  let parsed = null
190
- if (pluginName && category.ok && tags.ok && subject.repository.url) {
268
+ if (!bodyWaitsOn.length) {
191
269
  issue = renderIssue(contract, {
192
270
  pluginName,
193
271
  repositoryUrl: subject.repository.url,
@@ -211,7 +289,8 @@ export async function submitPreflight(options) {
211
289
  source: "marketplace-pin",
212
290
  why: `The six form headings must appear in exact order: ${contract.headings.join(", ")}. 11 open submissions are malformed in the body and receive "The validation result could not be published to the issue. A maintainer must review the workflow.", which blames the maintainer for the author's mistake; one of them differs from a valid submission by the single word "Suggested" instead of "Suggest". The headings are rendered from the form at the pin, never typed.`,
213
291
  verdict: Boolean(issue),
214
- detail: issue ? `${contract.headings.length} headings rendered in form order` : "not rendered: an earlier submission field is missing",
292
+ detail: issue ? `${contract.headings.length} headings rendered in form order` : "not rendered",
293
+ waitedOn: bodyWaitsOn,
215
294
  }))
216
295
 
217
296
  checks.push(check("submission.checklist", {
@@ -219,6 +298,7 @@ export async function submitPreflight(options) {
219
298
  why: `All ${contract.checklist.length} checklist items must be present with their exact text and checked; the marketplace refuses \`submission-checklist-unconfirmed\` otherwise. The text is read from the form at the pin, character for character.`,
220
299
  verdict: Boolean(issue),
221
300
  detail: issue ? `${contract.checklist.length} items rendered with the form's exact text, all checked` : "not rendered",
301
+ waitedOn: bodyWaitsOn,
222
302
  }))
223
303
 
224
304
  checks.push(check("submission.official-parser", {
@@ -230,6 +310,7 @@ export async function submitPreflight(options) {
230
310
  ? `accepted: repo ${parsed.submission.repo}, category ${parsed.submission.category}, tags ${parsed.submission.tags.join(", ")}`
231
311
  : `refused by the marketplace's own parser: ${parsed.code}, ${parsed.message}`
232
312
  : "not run: no body was rendered",
313
+ waitedOn: bodyWaitsOn,
233
314
  }))
234
315
 
235
316
  // --- the commit the marketplace will actually validate ---------------------
@@ -297,6 +378,7 @@ export async function submitPreflight(options) {
297
378
 
298
379
  const blocking = checks.filter((entry) => entry.severity === "blocking" && entry.verdict === "fail")
299
380
  const advisory = checks.filter((entry) => entry.severity === "advisory" && entry.verdict === "fail")
381
+ const unknown = checks.filter((entry) => entry.verdict === "unknown")
300
382
  const ready = blocking.length === 0
301
383
 
302
384
  return {
@@ -313,6 +395,12 @@ export async function submitPreflight(options) {
313
395
  commit: subject.commit,
314
396
  cleanTree: subject.clean,
315
397
  },
398
+ registry: {
399
+ source: live.source,
400
+ commit: live.commit,
401
+ fetchedAt: live.fetchedAt,
402
+ reason: live.reason,
403
+ },
316
404
  validationCommit: {
317
405
  local: subject.commit,
318
406
  defaultBranchHead: head?.commit || null,
@@ -325,6 +413,7 @@ export async function submitPreflight(options) {
325
413
  ready,
326
414
  blocking: blocking.map((entry) => entry.id),
327
415
  advisory: advisory.map((entry) => entry.id),
416
+ unknown: unknown.map((entry) => entry.id),
328
417
  issue: ready ? issue : null,
329
418
  baseline: preflight.invoked
330
419
  ? {