omakit 0.1.6 → 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.
@@ -13,8 +13,8 @@
13
13
 
14
14
  import { resolveSubject, SubjectError } from "../subject/resolve.mjs"
15
15
  import { requirePin } from "./pin.mjs"
16
- import { submissionContract, resolveCategory, resolveTags, tagSlug } from "./form.mjs"
17
- import { idUniverse, checkIdentity, baselineFigures, figure, liveRegistry, registrySourceDetail, catalogPresentation, defaultPresentation } from "./registry.mjs"
16
+ import { submissionContract, newerCommitChoice, resolveCategory, resolveTags, tagSlug } from "./form.mjs"
17
+ import { idUniverse, checkIdentity, listingOf, baselineFigures, figure, liveRegistry, registrySourceDetail, catalogPresentation, defaultPresentation } 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,26 +23,16 @@ 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
26
 
40
27
  /**
41
28
  * One arrow per cause, in this order, so a person fixes the thing that is
42
29
  * actually wrong. Measured before this: an id listed by its own repository
43
30
  * was told to "choose an unused plugin id outside the reserved namespace",
44
31
  * which is the remedy for a different failure, and an author who followed
45
- * it would have renamed a plugin the marketplace already lists.
32
+ * it would have renamed a plugin the marketplace already lists. The last
33
+ * arrow is for a listed repository whose manifest carries another id, or
34
+ * one with a second cause beside it; a plugin listed by its own repository
35
+ * with nothing else wrong is not a failure at all (see `listing` below).
46
36
  */
47
37
  function identityRemedies(identity, universe, newerCommit) {
48
38
  const remedies = []
@@ -66,7 +56,7 @@ export class SubmitError extends Error {
66
56
  }
67
57
 
68
58
  /**
69
- * 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
70
60
  * for a check that could not run because one it depends on failed: it is
71
61
  * rendered as a question, its detail names what it waited on, and it counts
72
62
  * in neither `blocking` nor `advisory`, so a refusal lists root causes only.
@@ -75,19 +65,28 @@ export class SubmitError extends Error {
75
65
  * checklist and official-parser each failed for want of a body nobody could
76
66
  * render yet, and the closing refusal listed them with "not rendered" where
77
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.
78
76
  */
79
77
  function check(id, fields) {
80
78
  const waitedOn = (fields.waitedOn || []).filter(Boolean)
79
+ const skipped = fields.skipped === true
81
80
  return {
82
81
  id,
83
82
  source: fields.source,
84
83
  why: fields.why,
85
84
  severity: fields.severity || "blocking",
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 || "",
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 || "",
88
87
  paths: fields.paths || [],
89
88
  // 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,
89
+ remedy: skipped || waitedOn.length ? null : Array.isArray(fields.remedy) ? (fields.remedy.length ? fields.remedy : null) : fields.remedy || null,
91
90
  }
92
91
  }
93
92
 
@@ -241,9 +240,30 @@ export async function submitPreflight(options) {
241
240
  // --- identity -------------------------------------------------------------
242
241
 
243
242
  const identity = checkIdentity(universe, { id: tree.pluginId, repositoryUrl: subject.repository.url })
244
- const identityRemedy = identity.ok ? null : identityRemedies(identity, universe, await newerCommitAction(pinDir))
243
+ const update = await newerCommitChoice({ pinDir })
244
+ const identityRemedy = identity.ok || identity.own ? null : identityRemedies(identity, universe, update.choice)
245
245
  const listed = identity.problems.some((problem) => problem.code === "plugin-id-listed" || problem.code === "submission-repository-listed")
246
246
 
247
+ // The subject's own listing is the third outcome of a run, not a failed
248
+ // check. Measured on 0.1.6: `omakit submit` on the author's own listed
249
+ // plugin printed FAIL identity.available and REFUSED, closed with "Fix it,
250
+ // then run submit again", and the remedy under it said there was nothing to
251
+ // submit. A healthy state was drawn as a failure and the closing line
252
+ // contradicted the remedy. Now the check passes with what the marketplace
253
+ // records about the listing, and the run ends in LISTED.
254
+ const listing = identity.own
255
+ ? (() => {
256
+ const record = listingOf(live, tree.pluginId) || { repository: subject.repository.url, id: tree.pluginId, addedAt: null, verificationCommit: null, verificationStatus: null, verificationCheckedAt: null }
257
+ return {
258
+ ...record,
259
+ localCommit: subject.commit,
260
+ sameCommit: Boolean(record.verificationCommit) && record.verificationCommit === subject.commit.toLowerCase(),
261
+ source: live.source,
262
+ updateRoute: { form: update.name, choice: update.choice },
263
+ }
264
+ })()
265
+ : null
266
+
247
267
  // The category and the tags are decided here, after the registry. Measured
248
268
  // before this: a listed plugin was asked for both and then told there was
249
269
  // nothing to submit. A listed plugin needs neither; an unlisted plugin with
@@ -268,11 +288,13 @@ export async function submitPreflight(options) {
268
288
  }
269
289
  checks.push(check("identity.available", {
270
290
  source: "marketplace-pin",
271
- 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.`,
272
- verdict: identity.ok,
291
+ 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. A plugin listed by its own repository (owner and name, case-insensitively, a trailing .git ignored) is not refused: it is listed, and this check says since when and at which commit.`,
292
+ verdict: identity.ok || identity.own,
273
293
  detail: `${identity.ok
274
294
  ? `id "${tree.pluginId}" is unused, outside the reserved ${universe.reservedPrefix}* namespace, and the repository is not listed`
275
- : identity.problems.map((problem) => `${problem.code}: ${problem.detail}`).join("; ")}; ${registrySourceDetail(live)}`,
295
+ : listing
296
+ ? `listed by this repository since ${listing.addedAt || "an unrecorded date"}, verification commit ${listing.verificationCommit || "unrecorded"} (${listing.verificationStatus || "status unrecorded"}, checked ${listing.verificationCheckedAt || "at an unrecorded time"})`
297
+ : identity.problems.map((problem) => `${problem.code}: ${problem.detail}`).join("; ")}; ${registrySourceDetail(live)}`,
276
298
  remedy: identityRemedy,
277
299
  }))
278
300
 
@@ -295,7 +317,11 @@ export async function submitPreflight(options) {
295
317
  remedy: "Give the plugin a name in manifest.json, or pass --name.",
296
318
  }))
297
319
 
298
- checks.push(check("submission.category", {
320
+ // On the subject's own listing no body is rendered, on purpose: the
321
+ // submission form is not the route. The five checks that exist only for the
322
+ // body are omitted rather than drawn as questions waiting on identity,
323
+ // because identity did not fail.
324
+ if (!listing) checks.push(check("submission.category", {
299
325
  source: "marketplace-pin",
300
326
  why: `Exactly one category from the form's controlled list; the marketplace refuses \`submission-category-invalid\` otherwise. The list (${contract.categories.length} options) is read from ${contract.formPath} at the pin.`,
301
327
  verdict: category.ok,
@@ -304,7 +330,7 @@ export async function submitPreflight(options) {
304
330
  waitedOn: mootWaitsOn,
305
331
  }))
306
332
 
307
- checks.push(check("submission.tags", {
333
+ if (!listing) checks.push(check("submission.tags", {
308
334
  source: "marketplace-pin",
309
335
  why: `1 to ${contract.maximumTags} tags from the form's controlled list; the marketplace refuses \`submission-tag-count-invalid\` and \`submission-tags-invalid\` otherwise. The list (${contract.tagLabels.length} options) and the maximum are read from the pin.`,
310
336
  verdict: tags.ok,
@@ -323,7 +349,7 @@ export async function submitPreflight(options) {
323
349
  ].filter(Boolean)
324
350
  let issue = null
325
351
  let parsed = null
326
- if (!bodyWaitsOn.length) {
352
+ if (!bodyWaitsOn.length && !listing) {
327
353
  issue = renderIssue(contract, {
328
354
  pluginName,
329
355
  repositoryUrl: subject.repository.url,
@@ -343,7 +369,7 @@ export async function submitPreflight(options) {
343
369
  remedy: subject.repository.url ? null : "Give the repository a github.com origin remote.",
344
370
  }))
345
371
 
346
- checks.push(check("submission.headings", {
372
+ if (!listing) checks.push(check("submission.headings", {
347
373
  source: "marketplace-pin",
348
374
  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.`,
349
375
  verdict: Boolean(issue),
@@ -351,7 +377,7 @@ export async function submitPreflight(options) {
351
377
  waitedOn: bodyWaitsOn,
352
378
  }))
353
379
 
354
- checks.push(check("submission.checklist", {
380
+ if (!listing) checks.push(check("submission.checklist", {
355
381
  source: "marketplace-pin",
356
382
  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.`,
357
383
  verdict: Boolean(issue),
@@ -359,7 +385,7 @@ export async function submitPreflight(options) {
359
385
  waitedOn: bodyWaitsOn,
360
386
  }))
361
387
 
362
- checks.push(check("submission.official-parser", {
388
+ if (!listing) checks.push(check("submission.official-parser", {
363
389
  source: "marketplace-pin",
364
390
  why: "The strongest available proof that the body is well formed: the marketplace's own `parseCurrentSubmission` from the pinned commit is run over the rendered title and body. If it accepts them here it accepts them there, and 0 of the marketplace's heading, tag and checklist rules are duplicated in Omakit, so none of them can drift. This is the check that closes all 50 measured title-and-body failures at once: 39 on the title prefix plus 11 malformed bodies.",
365
391
  verdict: Boolean(parsed?.ok),
@@ -388,7 +414,8 @@ export async function submitPreflight(options) {
388
414
  source: "omakit",
389
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.",
390
416
  severity: options.offline ? "advisory" : "blocking",
391
- verdict: options.offline ? true : validationMatches === true,
417
+ skipped: options.offline === true,
418
+ verdict: validationMatches === true,
392
419
  detail: options.offline
393
420
  ? `not checked (--offline). Local commit ${subject.commit}.`
394
421
  : head
@@ -437,7 +464,13 @@ export async function submitPreflight(options) {
437
464
  const blocking = checks.filter((entry) => entry.severity === "blocking" && entry.verdict === "fail")
438
465
  const advisory = checks.filter((entry) => entry.severity === "advisory" && entry.verdict === "fail")
439
466
  const unknown = checks.filter((entry) => entry.verdict === "unknown")
440
- const ready = blocking.length === 0
467
+ const skipped = checks.filter((entry) => entry.verdict === "skipped")
468
+ // Three outcomes. `refused`: a blocking check failed and no body exists.
469
+ // `listed`: nothing failed and the plugin is already listed by this
470
+ // repository, so there is no body either, and nothing is wrong. `ready`:
471
+ // the body. `ready` the boolean stays what it was, true for the third only.
472
+ const outcome = blocking.length ? "refused" : listing ? "listed" : "ready"
473
+ const ready = outcome === "ready"
441
474
 
442
475
  return {
443
476
  pin: {
@@ -478,10 +511,13 @@ export async function submitPreflight(options) {
478
511
  offline: options.offline === true,
479
512
  }),
480
513
  checks,
514
+ outcome,
481
515
  ready,
516
+ listing,
482
517
  blocking: blocking.map((entry) => entry.id),
483
518
  advisory: advisory.map((entry) => entry.id),
484
519
  unknown: unknown.map((entry) => entry.id),
520
+ skipped: skipped.map((entry) => entry.id),
485
521
  issue: ready ? issue : null,
486
522
  baseline: preflight.invoked
487
523
  ? {
@@ -45,10 +45,13 @@ export const COMMANDS = Object.freeze([
45
45
  lines: [
46
46
  "Every check that is knowable before submitting, the resolved commit, and",
47
47
  "the exact issue title and body. Prints them. Never posts anything.",
48
- "A listed plugin is refused at identity and needs no category or tags.",
49
- "An unlisted one without them is asked at a terminal, with the form's",
50
- "lists numbered; in a pipe or with --json that is a usage error, exit 2.",
51
- "The report ends with the command line that repeats the run unasked.",
48
+ "Three outcomes: READY (exit 0, the body), REFUSED (exit 1, no body), and",
49
+ "LISTED (exit 0): the plugin is already listed by its own repository, so",
50
+ "the submission form is not the route and nothing is asked. An id taken",
51
+ "by another repository is refused. An unlisted plugin without a category",
52
+ "and tags is asked at a terminal, with the form's lists numbered; in a",
53
+ "pipe or with --json that is a usage error, exit 2. A READY or REFUSED",
54
+ "report ends with the command line that repeats the run unasked.",
52
55
  ],
53
56
  },
54
57
  {
@@ -115,10 +118,11 @@ export const TARGET_NOTE = "<target> is a local Git repository path, or <https u
115
118
  */
116
119
  export const AUTHENTICATION = Object.freeze([
117
120
  "Read-only, and optional. omakit uses your `gh` login if you have one, and",
118
- "otherwise goes unauthenticated. `verify` needs no network; `submit` reads",
119
- "two things online and `--offline` turns both off; `watch` and `parity` are",
120
- `capped at ${UNAUTHENTICATED_LIMIT} requests an hour without a login. omakit never writes a`,
121
- "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.",
122
126
  ])
123
127
 
124
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",