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,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",
@@ -27,6 +27,8 @@ import { requirePin } from "./pin.mjs"
27
27
 
28
28
  export const SUBMIT_FORM_PATH = ".github/ISSUE_TEMPLATE/submit-plugin.yml"
29
29
  export const OFFICIAL_SUBMISSION_MODULE = "scripts/submission.mjs"
30
+ export const VERIFY_FORM_PATH = ".github/ISSUE_TEMPLATE/verify-plugin.yml"
31
+ export const OFFICIAL_VERIFICATION_MODULE = "scripts/plugin-verification-request.mjs"
30
32
 
31
33
  export class ContractError extends Error {
32
34
  constructor(code, message) {
@@ -50,8 +52,8 @@ function loadOfficial(pinDir) {
50
52
  return import(pathToFileURL(join(pinDir, OFFICIAL_SUBMISSION_MODULE)).href)
51
53
  }
52
54
 
53
- function fieldsOf(form) {
54
- if (!Array.isArray(form?.body)) throw new ContractError("form-unreadable", `${SUBMIT_FORM_PATH} has no body`)
55
+ function fieldsOf(form, formPath = SUBMIT_FORM_PATH) {
56
+ if (!Array.isArray(form?.body)) throw new ContractError("form-unreadable", `${formPath} has no body`)
55
57
  return form.body
56
58
  .filter((item) => item && item.type !== "markdown")
57
59
  .map((item) => ({
@@ -65,12 +67,12 @@ function fieldsOf(form) {
65
67
  }))
66
68
  }
67
69
 
68
- function only(fields, predicate, what) {
70
+ function only(fields, predicate, what, formPath = SUBMIT_FORM_PATH) {
69
71
  const found = fields.filter(predicate)
70
72
  if (found.length !== 1) {
71
73
  throw new ContractError(
72
74
  "form-shape-changed",
73
- `${SUBMIT_FORM_PATH} no longer has exactly one ${what} (found ${found.length}); the pin changed shape and the submission contract must be re-read before anything is generated`,
75
+ `${formPath} no longer has exactly one ${what} (found ${found.length}); the pin changed shape and the submission contract must be re-read before anything is generated`,
74
76
  )
75
77
  }
76
78
  return found[0]
@@ -135,6 +137,36 @@ export async function submissionContract(options = {}) {
135
137
  return contract
136
138
  }
137
139
 
140
+ /**
141
+ * The route for a plugin that is already listed: the marketplace's other
142
+ * form, and the one choice on it that lists a newer commit. Both read from
143
+ * the pin. The choice text is the option as the form spells it, found by the
144
+ * name the marketplace's own verification module gives that action; the two
145
+ * must agree, the way the submission form and `scripts/submission.mjs` must,
146
+ * because a choice retyped here would drift by a word and send a person to
147
+ * pick something the form no longer offers.
148
+ *
149
+ * @param {{ repoRoot?: string, pinDir?: string }} [options]
150
+ * @returns {Promise<{ formPath: string, name: string, choice: string }>}
151
+ */
152
+ export async function newerCommitChoice(options = {}) {
153
+ const pinDir = options.pinDir || requirePin(options.repoRoot).dir
154
+ const form = parseYaml(readFileSync(join(pinDir, VERIFY_FORM_PATH), "utf8"))
155
+ const fields = fieldsOf(form, VERIFY_FORM_PATH)
156
+ const action = only(fields, (f) => f.type === "dropdown" && !f.multiple && f.required, "required single-select dropdown (verification action)", VERIFY_FORM_PATH)
157
+ const official = await import(pathToFileURL(join(pinDir, OFFICIAL_VERIFICATION_MODULE)).href)
158
+ const choice = (action.options || []).find((option) => option === official.upstreamUpdateVerificationAction)
159
+ if (!choice) {
160
+ throw new ContractError(
161
+ "form-shape-changed",
162
+ `${VERIFY_FORM_PATH} offers no "${official.upstreamUpdateVerificationAction}" under ${action.label}; the form and ${OFFICIAL_VERIFICATION_MODULE} at the pin disagree, and nobody is sent to a choice that is not there`,
163
+ )
164
+ }
165
+ const name = typeof form.name === "string" ? form.name.trim() : ""
166
+ if (!name) throw new ContractError("form-shape-changed", `${VERIFY_FORM_PATH} has no name`)
167
+ return { formPath: VERIFY_FORM_PATH, name, choice }
168
+ }
169
+
138
170
  /**
139
171
  * The form and the marketplace's own constants must describe the same
140
172
  * submission. Anything else means the pin is internally inconsistent, and
@@ -107,12 +107,32 @@ export function defaultPresentation(presentation, kinds = []) {
107
107
  return { category, tags }
108
108
  }
109
109
 
110
+ /**
111
+ * "https://github.com/Owner/Name.git" and "owner/name" both become
112
+ * "owner/name": the path, without surrounding slashes, without a trailing
113
+ * .git, lowercased. Anything else is "", which matches nothing.
114
+ */
110
115
  function repositorySlug(value) {
116
+ const text = String(value ?? "").trim()
117
+ let path
111
118
  try {
112
- return new URL(String(value)).pathname.replace(/^\/+|\/+$/g, "").replace(/\.git$/i, "").toLowerCase()
119
+ path = new URL(text).pathname
113
120
  } catch {
114
- return ""
121
+ path = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(text) ? text : ""
115
122
  }
123
+ return path.replace(/^\/+|\/+$/g, "").replace(/\.git$/i, "").toLowerCase()
124
+ }
125
+
126
+ /**
127
+ * The one rule for "the same repository": owner and name, compared
128
+ * case-insensitively, with a trailing .git ignored. Measured on 0.1.6: the
129
+ * author's own listed plugin was rendered as a failed check because the
130
+ * listing and the subject were compared as two strings, so this is the rule
131
+ * and its test, not a comparison typed where it is needed.
132
+ */
133
+ export function sameRepository(a, b) {
134
+ const slug = repositorySlug(a)
135
+ return Boolean(slug) && slug === repositorySlug(b)
116
136
  }
117
137
 
118
138
  /**
@@ -400,11 +420,14 @@ export function figure(n) {
400
420
 
401
421
  /**
402
422
  * @param {{ id: string, repositoryUrl?: string|null }} subject
403
- * @returns {{ ok: boolean, problems: Array<{ code: string, detail: string, repository?: string|null, sameRepository?: boolean }> }}
423
+ * @returns {{ ok: boolean, own: boolean, problems: Array<{ code: string, detail: string, repository?: string|null, sameRepository?: boolean }> }}
404
424
  * A `plugin-id-listed` problem names the repository that lists the id
405
425
  * (`repository`, a slug, or null when the registry does not say) and
406
426
  * whether that is the subject's own (`sameRepository`), because the two
407
- * have different remedies: another id, or nothing to submit at all.
427
+ * are different states: another id is needed, or the plugin is listed and
428
+ * there is nothing to submit. `own` is the second state on its own: the id
429
+ * is listed by this repository and no other code fired, so `ok` is false
430
+ * and nothing is wrong.
408
431
  */
409
432
  export function checkIdentity(universe, subject) {
410
433
  const problems = []
@@ -425,18 +448,64 @@ export function checkIdentity(universe, subject) {
425
448
  }
426
449
  if (universe.listedIds.has(id)) {
427
450
  const repository = universe.listedBy?.get(id) || null
428
- const sameRepository = Boolean(repository && slug && repository === slug)
429
451
  problems.push({
430
452
  code: "plugin-id-listed",
431
453
  detail: `"${id}" is already listed${repository ? ` by ${repository}` : ""}`,
432
454
  repository,
433
- sameRepository,
455
+ sameRepository: sameRepository(repository, slug),
434
456
  })
435
457
  }
436
458
  if (slug && universe.listedRepositories.has(slug)) {
437
459
  problems.push({ code: "submission-repository-listed", detail: `${slug} is already listed`, repository: slug, sameRepository: true })
438
460
  }
439
- return { ok: problems.length === 0, problems }
461
+ // The subject's own listing: the id is listed, by this repository, and
462
+ // nothing else is wrong with the id. That is not a problem with the
463
+ // submission, it is the absence of one; the caller reports it as a state.
464
+ const own = problems.length > 0
465
+ && problems.some((problem) => problem.code === "plugin-id-listed" && problem.sameRepository)
466
+ && problems.every((problem) => problem.sameRepository)
467
+ return { ok: problems.length === 0, own, problems }
468
+ }
469
+
470
+ /**
471
+ * What the marketplace records about one listing, for the plugin that is
472
+ * already listed by its own repository: read from the catalog first, which
473
+ * carries the verification fields, and from the registry source when the
474
+ * catalog has no entry. Every field is null when nothing records it; nothing
475
+ * is guessed.
476
+ *
477
+ * @param {{ registry: object, catalog: object }} live
478
+ * @param {string} id
479
+ * @returns {{ repository: string|null, id: string, addedAt: string|null, verificationCommit: string|null,
480
+ * verificationStatus: string|null, verificationCheckedAt: string|null }|null}
481
+ */
482
+ export function listingOf(live, id) {
483
+ const text = (value) => (typeof value === "string" && value ? value : null)
484
+ const plugin = (Array.isArray(live.catalog?.plugins) ? live.catalog.plugins : []).find((entry) => entry?.id === id)
485
+ if (plugin) {
486
+ return {
487
+ repository: text(plugin.repo),
488
+ id,
489
+ addedAt: text(plugin.addedAt) || text(plugin.listedAt),
490
+ verificationCommit: text(plugin.verificationCommit)?.toLowerCase() || text(plugin.listingValidatedCommit)?.toLowerCase() || null,
491
+ verificationStatus: text(plugin.verificationStatus),
492
+ verificationCheckedAt: text(plugin.verificationCheckedAt) || text(plugin.listingValidatedAt),
493
+ }
494
+ }
495
+ const sources = Array.isArray(live.registry?.sources) ? live.registry.sources : Object.values(live.registry?.sources || {})
496
+ const source = sources.find((entry) => {
497
+ const ids = entry?.plugins && typeof entry.plugins === "object" && !Array.isArray(entry.plugins) ? Object.keys(entry.plugins) : []
498
+ return ids.includes(id) || (entry?.automatedSecurityBaseline?.pluginIds || []).includes(id)
499
+ })
500
+ if (!source) return null
501
+ return {
502
+ repository: text(source.repo),
503
+ id,
504
+ addedAt: text(source.addedAt) || text(source.listedAt),
505
+ verificationCommit: text(source.listingValidatedCommit)?.toLowerCase() || null,
506
+ verificationStatus: text(source.automatedSecurityBaseline?.outcome),
507
+ verificationCheckedAt: text(source.listingValidatedAt),
508
+ }
440
509
  }
441
510
 
442
511
  export { repositorySlug }
@@ -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)
@@ -122,7 +123,26 @@ export function renderSubmit(result, { colour = colourEnabled() } = {}) {
122
123
  out.push("")
123
124
  }
124
125
 
125
- if (!result.ready) {
126
+ if (result.outcome === "listed") {
127
+ // The third outcome. Nothing was refused and nothing is wrong: the plugin
128
+ // is listed by this repository, so there is no body and no reproduce
129
+ // line, and the route to a newer commit is the marketplace's other form.
130
+ // Measured on 0.1.6: this state was drawn as FAIL and REFUSED, and closed
131
+ // with "Fix it, then run submit again" under a remedy that said there was
132
+ // nothing to submit.
133
+ const listing = result.listing
134
+ out.push(...verdict("pass", "LISTED", `${listing.id} is already listed by this repository, so the submission form is not the route.`, c))
135
+ out.push("")
136
+ out.push(...field("listed", listing.verificationCommit ? c("name", listing.verificationCommit) : "no verification commit recorded", c, { wrapValue: false }))
137
+ out.push(...continuation(`${listing.verificationStatus || "status unrecorded"}, checked ${listing.verificationCheckedAt || "at an unrecorded time"}, read from the ${listing.source === "head" ? "marketplace's current HEAD" : "pin"}`, c))
138
+ out.push(...field("local HEAD", c("name", listing.localCommit), c, { wrapValue: false }))
139
+ out.push(...continuation(listing.sameCommit ? "the same commit" : "not the listed commit", c))
140
+ out.push("")
141
+ out.push(...wrap(`${listing.sameCommit ? "To get a newer commit listed later" : "To get it listed"}, open the marketplace's "${listing.updateRoute.form}" form and choose "${listing.updateRoute.choice}". \`omakit watch <the submission issue>\` shows which commit is listed now.`, {}, c))
142
+ return out.join("\n")
143
+ }
144
+
145
+ if (result.outcome === "refused") {
126
146
  // Root causes only: a check that waited on a failed one is not listed,
127
147
  // and the count says how many waited.
128
148
  const failed = result.checks.filter((check) => result.blocking.includes(check.id))
@@ -149,7 +169,12 @@ export function renderSubmit(result, { colour = colourEnabled() } = {}) {
149
169
  const validation = result.validationCommit.defaultBranchHead
150
170
  ? `${result.validationCommit.local} is the ${result.validationCommit.branch || "default"}-branch HEAD, so it is the commit the marketplace will validate.`
151
171
  : `${result.validationCommit.local} is the local commit; the marketplace validates the default-branch HEAD it resolves when the issue is opened.`
152
- 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))
153
178
  out.push("")
154
179
  out.push(...section("issue title", c))
155
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) {