omakit 0.1.3 → 0.1.5

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,6 +2,8 @@
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)
6
+
5
7
  **Everything knowable about an Omarchy Quattro plugin submission, checked
6
8
  before you post it:** the tree, the manifest, the form, the commit, and the
7
9
  marketplace's own security baseline with its outcome reported as it is. A
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omakit",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
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",
@@ -24,7 +24,9 @@ comes from `origin`. You supply the category and the tags, because nobody else
24
24
  can: they are an editorial choice about where the plugin belongs.
25
25
 
26
26
  If you do not know which category and tags are allowed, run the command without
27
- them. The failing checks print the controlled lists, read from the pinned form.
27
+ them: that is a usage error (exit 2) whose message lists the controlled values,
28
+ read from the pinned form, before any check runs. With `--json` the same lists
29
+ come back under `usage`.
28
30
 
29
31
  Useful flags: `--notes` for the Maintainer notes field, `--suggest-tag` for the
30
32
  optional suggestion, `--name` when the manifest has no name, `--json` for a
@@ -40,7 +42,12 @@ HEAD, tell the owner and choose another id.
40
42
  ## Reading the result
41
43
 
42
44
  Exit code 0 means every blocking check passed and the output contains the issue
43
- title and body. Exit code 1 means it refused, and no body was produced.
45
+ title and body. Exit code 1 means it refused, and no body was produced. Exit
46
+ code 2 is a usage error: nothing was checked.
47
+
48
+ A check drawn as `▒ ?` did not run because one it depends on failed; its detail
49
+ names that check. It is not a failure of its own, and the closing refusal lists
50
+ root causes only. Fix those.
44
51
 
45
52
  Each check names a source. `[marketplace-pin]` is the marketplace's own rule, read
46
53
  from a pinned checkout. `[omakit]` is this tool's own check, derived from public
@@ -17,7 +17,8 @@ import { fileURLToPath } from "node:url"
17
17
  import { ensurePin, MARKETPLACE_PIN } from "./pin.mjs"
18
18
  import { marketplaceBaselineSection } from "./verify.mjs"
19
19
  import { resolveSubject, SubjectError } from "../subject/resolve.mjs"
20
- import { submitPreflight } from "./submit.mjs"
20
+ import { missingSubmitFlags, submitPreflight } from "./submit.mjs"
21
+ import { submissionContract } from "./form.mjs"
21
22
  import { validationWatch } from "./watch.mjs"
22
23
  import { renderSubmit, renderWatch, renderDoctor } from "./report.mjs"
23
24
  import { doctor } from "./doctor.mjs"
@@ -26,7 +27,7 @@ import { upgrade } from "./upgrade.mjs"
26
27
  import { progress } from "./progress.mjs"
27
28
  import { banner, bannerEnabled } from "./banner.mjs"
28
29
  import { COMMANDS, renderSummary, renderUsage, TAGLINE } from "./usage.mjs"
29
- import { action, colourEnabled, GUTTER, mark, styler, wrap } from "./style.mjs"
30
+ import { action, colourEnabled, GUTTER, labelled, mark, styler, wrap } from "./style.mjs"
30
31
  import { omakitCacheDir } from "./paths.mjs"
31
32
  import { parityOutput } from "./parity-output.mjs"
32
33
 
@@ -55,11 +56,12 @@ const REMEDY = Object.freeze({
55
56
  /**
56
57
  * Every failure, in one register, on stderr. `usage` errors carry the
57
58
  * signature that was expected, so the remedy is the reference and not a
58
- * restatement of the message.
59
+ * restatement of the message. `body` is extra labelled lines between the
60
+ * message and the arrow, for a usage error that has values to list.
59
61
  */
60
- function fail(code, message, exit = 1, remedy = REMEDY[code]) {
62
+ function fail(code, message, exit = 1, remedy = REMEDY[code], body = () => []) {
61
63
  const c = styler(colourEnabled(process.stderr))
62
- const lines = [`${mark("fail", c)}${c("name", code)}`, ...wrap(message, { indent: GUTTER }, c)]
64
+ const lines = [`${mark("fail", c)}${c("name", code)}`, ...wrap(message, { indent: GUTTER }, c), ...body(c)]
63
65
  if (remedy) lines.push(...action(remedy, c))
64
66
  process.stderr.write(`${lines.join("\n")}\n`)
65
67
  process.exit(exit)
@@ -96,6 +98,28 @@ function emit(args, text) {
96
98
  async function cmdSubmit(args) {
97
99
  const target = positionals(args)[0]
98
100
  if (!target) fail("usage", "submit needs a target: `omakit submit <target> --category <c> --tags <a,b>`", 2)
101
+ // A missing category or tag list is a usage error, decided before any check
102
+ // runs and before the spinner starts: nothing can be rendered without them,
103
+ // and the values the form accepts are the answer, read from the pin.
104
+ let usage = null
105
+ try {
106
+ usage = missingSubmitFlags(await submissionContract({ repoRoot: ROOT }), { category: option(args, "--category"), tags: option(args, "--tags") })
107
+ } catch (error) {
108
+ failFrom(error)
109
+ }
110
+ if (usage) {
111
+ if (args.includes("--json")) {
112
+ process.stdout.write(`${JSON.stringify({ usage }, null, 2)}\n`)
113
+ process.exit(2)
114
+ }
115
+ const flags = usage.missing.join(" and ")
116
+ fail("usage", `submit needs ${flags}: ${usage.missing.length === 1 ? "it is" : "they are"} an editorial choice nobody else can make, from the pinned form's own lists.`, 2,
117
+ `omakit submit ${target} --category <c> --tags <a,b>`,
118
+ (c) => [
119
+ ...labelled("categories", usage.categories.join(", "), c),
120
+ ...labelled(`tags, 1 to ${usage.maximumTags}`, usage.tags.join(", "), c),
121
+ ])
122
+ }
99
123
  const spinner = args.includes("--json") ? { phase: () => {}, done: () => {} } : progress()
100
124
  let result
101
125
  try {
@@ -106,9 +106,22 @@ export function token() {
106
106
  return credential().value
107
107
  }
108
108
 
109
- async function get(url, { accept = "application/vnd.github+json" } = {}) {
110
- const headers = { accept, "user-agent": USER_AGENT }
111
- const auth = token()
109
+ /** The one host the borrowed gh credential may be sent to. */
110
+ export const CREDENTIAL_HOST = "api.github.com"
111
+
112
+ async function get(url, { accept } = {}) {
113
+ const { host } = new URL(url)
114
+ // The credential is GitHub's and goes to GitHub's API and nowhere else.
115
+ // Measured before this held: `omakit upgrade` and `doctor` sent the gh
116
+ // token as a bearer to registry.npmjs.org, which answered 401, so both
117
+ // reported "the npm registry did not answer" on every machine with a gh
118
+ // login, and a GitHub credential had left GitHub.
119
+ const authenticated = host === CREDENTIAL_HOST
120
+ const headers = {
121
+ accept: accept || (authenticated ? "application/vnd.github+json" : "application/json"),
122
+ "user-agent": USER_AGENT,
123
+ }
124
+ const auth = authenticated ? token() : null
112
125
  if (auth) headers.authorization = `Bearer ${auth}`
113
126
  let response
114
127
  try {
@@ -236,7 +236,10 @@ export function registrySourceDetail(live) {
236
236
  * `registry` and `catalog` are the parsed files from liveRegistry(); without
237
237
  * them the pin's copies are read.
238
238
  * @returns {{ reservedPrefix: string, listedIds: Set<string>, retiredIds: Set<string>,
239
- * 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.
240
243
  */
241
244
  export function idUniverse(options = {}) {
242
245
  const pinDir = options.pinDir || requirePin(options.repoRoot).dir
@@ -244,8 +247,14 @@ export function idUniverse(options = {}) {
244
247
  const registry = options.registry || readJson(pinDir, REGISTRY_PATH)
245
248
 
246
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
+ }
247
256
  for (const plugin of Array.isArray(catalog.plugins) ? catalog.plugins : []) {
248
- if (typeof plugin?.id === "string" && plugin.id) listedIds.add(plugin.id)
257
+ if (typeof plugin?.id === "string" && plugin.id) listed(plugin.id, plugin.repo)
249
258
  }
250
259
  const catalogIds = listedIds.size
251
260
 
@@ -255,17 +264,17 @@ export function idUniverse(options = {}) {
255
264
  const slug = repositorySlug(source?.repo)
256
265
  if (slug) listedRepositories.add(slug)
257
266
  if (source?.plugins && typeof source.plugins === "object" && !Array.isArray(source.plugins)) {
258
- for (const id of Object.keys(source.plugins)) listedIds.add(id)
267
+ for (const id of Object.keys(source.plugins)) listed(id, source.repo)
259
268
  }
260
269
  if (Array.isArray(source?.plugins)) {
261
270
  for (const entry of source.plugins) {
262
271
  const id = typeof entry === "string" ? entry : entry?.id
263
- if (id) listedIds.add(id)
272
+ if (id) listed(id, source.repo)
264
273
  }
265
274
  }
266
- if (typeof source?.catalog?.id === "string") listedIds.add(source.catalog.id)
275
+ if (typeof source?.catalog?.id === "string") listed(source.catalog.id, source.repo)
267
276
  for (const id of source?.automatedSecurityBaseline?.pluginIds || []) {
268
- if (typeof id === "string") listedIds.add(id)
277
+ if (typeof id === "string") listed(id, source.repo)
269
278
  }
270
279
  }
271
280
 
@@ -281,6 +290,7 @@ export function idUniverse(options = {}) {
281
290
  listedIds,
282
291
  retiredIds,
283
292
  listedRepositories,
293
+ listedBy,
284
294
  counts: {
285
295
  catalogPlugins: catalogIds,
286
296
  registrySources: sources.length,
@@ -354,7 +364,11 @@ export function figure(n) {
354
364
 
355
365
  /**
356
366
  * @param {{ id: string, repositoryUrl?: string|null }} subject
357
- * @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.
358
372
  */
359
373
  export function checkIdentity(universe, subject) {
360
374
  const problems = []
@@ -363,6 +377,7 @@ export function checkIdentity(universe, subject) {
363
377
  problems.push({ code: "plugin-id-missing", detail: "the root manifest declares no id" })
364
378
  return { ok: false, problems }
365
379
  }
380
+ const slug = repositorySlug(subject.repositoryUrl)
366
381
  if (id.toLowerCase().startsWith(universe.reservedPrefix)) {
367
382
  problems.push({
368
383
  code: "reserved-plugin-id",
@@ -373,11 +388,17 @@ export function checkIdentity(universe, subject) {
373
388
  problems.push({ code: "plugin-id-retired", detail: `"${id}" was used by a previous listing (registry.json retiredPluginIds)` })
374
389
  }
375
390
  if (universe.listedIds.has(id)) {
376
- 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
+ })
377
399
  }
378
- const slug = repositorySlug(subject.repositoryUrl)
379
400
  if (slug && universe.listedRepositories.has(slug)) {
380
- 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 })
381
402
  }
382
403
  return { ok: problems.length === 0, problems }
383
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
  }
@@ -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,19 +65,49 @@ 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,
@@ -60,7 +123,7 @@ export async function submitPreflight(options) {
60
123
  const phase = options.onPhase || (() => {})
61
124
 
62
125
  phase("verifying the pinned marketplace checkout")
63
- const { identity: pinIdentity } = requirePin(repoRoot)
126
+ const { dir: pinDir, identity: pinIdentity } = requirePin(repoRoot)
64
127
  phase("reading the submission contract from the pin")
65
128
  const contract = await submissionContract({ repoRoot })
66
129
  phase(options.offline ? "reading the listed and retired plugin ids from the pin" : "reading the marketplace's current registry")
@@ -149,6 +212,7 @@ export async function submitPreflight(options) {
149
212
  // --- identity -------------------------------------------------------------
150
213
 
151
214
  const identity = checkIdentity(universe, { id: tree.pluginId, repositoryUrl: subject.repository.url })
215
+ const identityRemedy = identity.ok ? null : identityRemedies(identity, universe, await newerCommitAction(pinDir))
152
216
  checks.push(check("identity.available", {
153
217
  source: "marketplace-pin",
154
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.`,
@@ -156,7 +220,7 @@ export async function submitPreflight(options) {
156
220
  detail: `${identity.ok
157
221
  ? `id "${tree.pluginId}" is unused, outside the reserved ${universe.reservedPrefix}* namespace, and the repository is not listed`
158
222
  : identity.problems.map((problem) => `${problem.code}: ${problem.detail}`).join("; ")}; ${registrySourceDetail(live)}`,
159
- remedy: identity.ok ? null : "Choose an unused plugin id outside the reserved namespace.",
223
+ remedy: identityRemedy,
160
224
  }))
161
225
 
162
226
  // --- the submission itself ------------------------------------------------
@@ -191,9 +255,17 @@ export async function submitPreflight(options) {
191
255
  remedy: tags.ok ? null : "Pass --tags with 1 to 3 comma-separated values from the list.",
192
256
  }))
193
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)
194
266
  let issue = null
195
267
  let parsed = null
196
- if (pluginName && category.ok && tags.ok && subject.repository.url) {
268
+ if (!bodyWaitsOn.length) {
197
269
  issue = renderIssue(contract, {
198
270
  pluginName,
199
271
  repositoryUrl: subject.repository.url,
@@ -217,7 +289,8 @@ export async function submitPreflight(options) {
217
289
  source: "marketplace-pin",
218
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.`,
219
291
  verdict: Boolean(issue),
220
- 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,
221
294
  }))
222
295
 
223
296
  checks.push(check("submission.checklist", {
@@ -225,6 +298,7 @@ export async function submitPreflight(options) {
225
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.`,
226
299
  verdict: Boolean(issue),
227
300
  detail: issue ? `${contract.checklist.length} items rendered with the form's exact text, all checked` : "not rendered",
301
+ waitedOn: bodyWaitsOn,
228
302
  }))
229
303
 
230
304
  checks.push(check("submission.official-parser", {
@@ -236,6 +310,7 @@ export async function submitPreflight(options) {
236
310
  ? `accepted: repo ${parsed.submission.repo}, category ${parsed.submission.category}, tags ${parsed.submission.tags.join(", ")}`
237
311
  : `refused by the marketplace's own parser: ${parsed.code}, ${parsed.message}`
238
312
  : "not run: no body was rendered",
313
+ waitedOn: bodyWaitsOn,
239
314
  }))
240
315
 
241
316
  // --- the commit the marketplace will actually validate ---------------------
@@ -303,6 +378,7 @@ export async function submitPreflight(options) {
303
378
 
304
379
  const blocking = checks.filter((entry) => entry.severity === "blocking" && entry.verdict === "fail")
305
380
  const advisory = checks.filter((entry) => entry.severity === "advisory" && entry.verdict === "fail")
381
+ const unknown = checks.filter((entry) => entry.verdict === "unknown")
306
382
  const ready = blocking.length === 0
307
383
 
308
384
  return {
@@ -337,6 +413,7 @@ export async function submitPreflight(options) {
337
413
  ready,
338
414
  blocking: blocking.map((entry) => entry.id),
339
415
  advisory: advisory.map((entry) => entry.id),
416
+ unknown: unknown.map((entry) => entry.id),
340
417
  issue: ready ? issue : null,
341
418
  baseline: preflight.invoked
342
419
  ? {