omakit 0.4.0 → 0.4.2

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.
@@ -0,0 +1,76 @@
1
+ // M6 reproduction: open author-fixes issues, bot marker against commits.atom.
2
+ // All remote reads use the existing GET-only client. Unknowns stay unknown.
3
+ import { resolve } from "node:path"
4
+ import { pathToFileURL } from "node:url"
5
+ import { MARKETPLACE_PIN } from "./pin.mjs"
6
+ import { repositoryIssues, getText } from "./github.mjs"
7
+ import { validationWatch } from "./watch.mjs"
8
+
9
+ export function atomHead(feed, source) {
10
+ const commit = feed.match(/<id>tag:github\.com,2008:Grit::Commit\/([0-9a-f]{40})<\/id>/i)?.[1]
11
+ || feed.match(/\/commit\/([0-9a-f]{40})/i)?.[1]
12
+ if (!commit) throw new Error("No full HEAD commit in commits.atom")
13
+ return { source, commit: commit.toLowerCase(), branch: null,
14
+ committedAt: feed.match(/<updated>([^<]+)<\/updated>/)?.[1] || null }
15
+ }
16
+
17
+ export function stalenessCounts(rows) {
18
+ const stale = rows.filter((row) => row.verdict === "stale").length
19
+ const current = rows.filter((row) => row.verdict === "current").length
20
+ const compared = stale + current
21
+ return { total: rows.length, compared, stale, current, unknown: rows.length - compared,
22
+ staleShareOfCompared: compared ? stale / compared : null,
23
+ staleShareOfPopulation: compared === rows.length && rows.length ? stale / rows.length : null }
24
+ }
25
+
26
+ export async function measureStaleness(repoRoot, { discover = repositoryIssues, watch = validationWatch, readText = getText } = {}) {
27
+ const openedAt = new Date().toISOString()
28
+ const [owner, repository] = new URL(MARKETPLACE_PIN.repository).pathname.slice(1).split("/")
29
+ const batches = await Promise.all(["needs-fixes", "security-needs-fixes"].map((labels) => discover(owner, repository, undefined, { labels })))
30
+ const subjects = [...new Map(batches.flat().map((subject) => [subject.number, subject])).values()]
31
+ .filter((subject) => subject.state === "open" && !subject.pull_request)
32
+ .sort((a, b) => a.number - b.number)
33
+ const heads = new Map()
34
+ function readHead(url) {
35
+ if (!heads.has(url)) {
36
+ const source = `${url.replace(/\/$/, "")}/commits.atom`
37
+ heads.set(url, Promise.resolve().then(async () => atomHead(await readText(source, "application/atom+xml"), source)))
38
+ }
39
+ return heads.get(url)
40
+ }
41
+ const rows = new Array(subjects.length)
42
+ let next = 0
43
+ async function worker() {
44
+ while (next < subjects.length) {
45
+ const index = next++
46
+ const subject = subjects[index]
47
+ const issueUrl = `${MARKETPLACE_PIN.repository}/issues/${subject.number}`
48
+ const observedAt = new Date().toISOString()
49
+ try {
50
+ const report = await watch({ repoRoot, issueUrl, github: { issue: async () => subject, defaultBranchHead: readHead } })
51
+ rows[index] = { issue: subject.number, issueUrl, observedAt, completedAt: new Date().toISOString(),
52
+ repository: report.plugin.repository, validatedCommit: report.validated?.commit || null,
53
+ validationSource: report.validated?.source || null, validatedAt: report.validated?.checkedAt || null,
54
+ validationCommentsSource: `https://api.github.com/repos/${owner}/${repository}/issues/${subject.number}/comments`,
55
+ defaultBranchHead: report.head?.commit || null, headSource: report.head?.source || null,
56
+ verdict: report.verdict.state,
57
+ reason: report.verdict.state === "unknown" ? report.verdict.summary : null }
58
+ } catch (error) {
59
+ rows[index] = { issue: subject.number, issueUrl, observedAt, completedAt: new Date().toISOString(),
60
+ repository: null, validatedCommit: null, defaultBranchHead: null, verdict: "unknown",
61
+ reason: `${error.code || "read-unavailable"}: ${error.message}` }
62
+ }
63
+ }
64
+ }
65
+ await Promise.all(Array.from({ length: Math.min(4, subjects.length) }, worker))
66
+ return { measurement: "M6", date: openedAt.slice(0, 10), openedAt, completedAt: new Date().toISOString(),
67
+ marketplace: MARKETPLACE_PIN.repository, marketplacePin: MARKETPLACE_PIN.commit,
68
+ command: "node tools/marketplace/measure-staleness.mjs", sample: false,
69
+ population: "All open non-PR marketplace issues labelled needs-fixes or security-needs-fixes at discovery",
70
+ method: "Latest bot security-baseline full commit against default-branch commits.atom HEAD; missing full markers or feeds are unknown; unequal commits are stale, without a claim about ancestry",
71
+ ...stalenessCounts(rows), rows }
72
+ }
73
+
74
+ if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
75
+ process.stdout.write(`${JSON.stringify(await measureStaleness(resolve(process.cwd())), null, 2)}\n`)
76
+ }
@@ -18,7 +18,7 @@
18
18
  // screen, and the last screen is the one a person is looking at.
19
19
 
20
20
  import {
21
- action, colourEnabled, COLUMNS, continuation, field, GUTTER, labelled, mark, section, STEP, styler, verdict, width, wrap,
21
+ action, colourEnabled, outputColumns, continuation, field, GUTTER, labelled, mark, section, STEP, styler, verdict, width, wrap,
22
22
  } from "./style.mjs"
23
23
  import { withHomeAbbreviated } from "./paths.mjs"
24
24
  import { watchIssueTitle } from "./watch.mjs"
@@ -42,7 +42,11 @@ function stateOf(check) {
42
42
  export function head(state, id, source, c) {
43
43
  const left = `${mark(state, c)}${c("name", id)}`
44
44
  const tag = c("punctuation", `[${source}]`)
45
- const gap = Math.max(2, COLUMNS - width(left) - width(tag))
45
+ const columns = outputColumns()
46
+ if (width(left) + width(tag) + 2 > columns) {
47
+ return `${left}\n${wrap(`[${source}]`, { indent: GUTTER }).map((line) => c("punctuation", line)).join("\n")}`
48
+ }
49
+ const gap = columns - width(left) - width(tag)
46
50
  return `${left}${" ".repeat(gap)}${tag}`
47
51
  }
48
52
 
@@ -78,7 +82,7 @@ function commandLines(command, c) {
78
82
  units.push(word)
79
83
  }
80
84
  }
81
- const room = COLUMNS - STEP - " \\".length
85
+ const room = outputColumns() - STEP - " \\".length
82
86
  const lines = []
83
87
  let line = ""
84
88
  for (const unit of units) {
@@ -262,12 +266,19 @@ export function renderWatchList(result, { colour = colourEnabled() } = {}) {
262
266
  /** Compact batch report; exact commits and full discussion remain in JSON. */
263
267
  export function renderWatchAll(result, { colour = colourEnabled() } = {}) {
264
268
  const c = styler(colour)
265
- const out = [...field("account", result.account, c), ...field("issues", `${result.summary.total} checked; ${result.summary.current} current, ${result.summary.stale} stale, ${result.summary.unknown} unknown`, c), ""]
269
+ const out = [...field("account", result.account, c), ...field("issues", `${result.summary.total} checked; ${result.summary.current} current, ${result.summary.stale} stale, ${result.summary.unknown} unknown`, c)]
270
+ if (result.reviewCostSummary) {
271
+ const cost = result.reviewCostSummary
272
+ const skipped = cost.skipped.length ? `; ${cost.skipped.length} diff(s) skipped (reasons on issue rows)` : ""
273
+ out.push(...field("review cost", `${cost.manualQueue} of ${cost.pluginUpdates} plugin-update issue(s) on security-review-required; ${cost.docsOnly} docs-only validated diff(s) of ${cost.compared} compared${skipped}`, c))
274
+ }
275
+ out.push("")
266
276
  for (const row of result.issues) {
267
277
  const state = row.report?.verdict.state || "unknown"
268
278
  const style = { current: "pass", stale: "fail", unknown: "unknown" }[state]
269
279
  out.push(...verdict(style, state.toUpperCase(), `#${row.issue.number} ${watchIssueTitle(row.report?.read.title || row.issue.title)}`, c))
270
280
  out.push(...field("issue", row.issue.url, c, { wrapValue: false }))
281
+ if (row.documentationDiff?.docsOnly === null) out.push(...field("diff skipped", watchIssueTitle(row.documentationDiff.reason), c))
271
282
  if (row.error) {
272
283
  out.push(...field("read error", watchIssueTitle(`${row.error.code}: ${row.error.message}`), c))
273
284
  } else {
@@ -0,0 +1,107 @@
1
+ // Review cost is advice, never a marketplace rule. Evidence: MEASUREMENTS.md M4 and M9.
2
+ import { readFileSync } from "node:fs"
3
+ import { join } from "node:path"
4
+ import { pathToFileURL } from "node:url"
5
+ import { requirePin } from "./pin.mjs"
6
+ import { token, issue, parseIssueUrl, compareCommits } from "./github.mjs"
7
+ import { discoverWatchIssues, repositoryFor } from "./watch.mjs"
8
+ import { repositorySlug } from "./registry.mjs"
9
+
10
+ /** Outcome and update label names come from the pin, rather than a second policy. */
11
+ export async function reviewPolicy(repoRoot) {
12
+ const { dir } = requirePin(repoRoot)
13
+ const policy = await import(pathToFileURL(join(dir, "scripts/security-baseline-policy.mjs")).href)
14
+ const approval = readFileSync(join(dir, "scripts/approve-plugin-update.mjs"), "utf8")
15
+ const updateLabel = approval.match(/for \(const required of \["([^"]+)"/)?.[1]
16
+ if (!updateLabel) throw new Error("cannot read the update issue label from the pin")
17
+ const manual = policy.currentSecurityBaselinePolicy.maintainerVerificationOutcome
18
+ return { automated: policy.securityBaselineOutcome([], []), manual, updateLabel, reviewLabel: `security-${manual}` }
19
+ }
20
+
21
+ /** The same discovery as watch --all; no credential or incomplete reads are not zero issues. */
22
+ export async function openIssuesForRepository({ repoRoot, repository, offline = false, github = {} }) {
23
+ if (offline) return { count: null, reason: "not checked (--offline)" }
24
+ if (!(github.token || token)()) return { count: null, reason: "not checked: no GitHub credential" }
25
+ try {
26
+ const discovery = await discoverWatchIssues({ github })
27
+ const { dir } = requirePin(repoRoot)
28
+ let count = 0
29
+ let next = 0
30
+ async function worker() {
31
+ while (next < discovery.issues.length) {
32
+ const target = parseIssueUrl(discovery.issues[next++].url)
33
+ const subject = await (github.issue || issue)(target.owner, target.repository, target.number)
34
+ const parsed = await repositoryFor(dir, subject)
35
+ if (!parsed.url) throw new Error(`repository unknown on issue #${target.number}: ${parsed.error}`)
36
+ if (repositorySlug(parsed.url) === repositorySlug(repository)) count += 1
37
+ }
38
+ }
39
+ await Promise.all(Array.from({ length: Math.min(4, discovery.issues.length) }, worker))
40
+ return { count, reason: `watch --all discovery: ${count} open issue(s) for this repository` }
41
+ } catch (error) {
42
+ return { count: null, reason: `not checked (${error.code || "issue-discovery-unavailable"}): ${error.message}` }
43
+ }
44
+ }
45
+
46
+ export function reviewCostVerdict({ baseline, policy, openIssues = { count: null, reason: "not checked" }, why }) {
47
+ const capabilities = [...(baseline?.capabilities || [])]
48
+ const manual = baseline?.outcome === policy.manual
49
+ const automated = baseline?.outcome === policy.automated
50
+ const outcome = manual ? openIssues.count > 0 ? "manual queue, again" : "manual queue" : automated ? "automated" : null
51
+ const detail = manual
52
+ ? `${outcome}: capabilities ${capabilities.join(", ") || "none"}. Every update of this plugin, including a docs-only one, lands in the manual queue.${openIssues.count > 0 ? ` You already have ${openIssues.count} open issue(s) for this repository.` : ""}`
53
+ : automated ? "automated: this update will not need a human for the security baseline." : "not checked: baseline.preflight has no passed or review-required outcome"
54
+ return {
55
+ reviewCost: { outcome, capabilities, openIssuesForRepository: openIssues.count, reason: `${detail} ${openIssues.reason}` },
56
+ check: {
57
+ id: "review.cost", source: "omakit", severity: "advisory", verdict: manual ? "fail" : automated ? "pass" : "unknown",
58
+ why, detail, paths: [],
59
+ remedy: manual && openIssues.count > 0 ? "consider batching: close or fold the open one before opening another" : null,
60
+ },
61
+ }
62
+ }
63
+
64
+ /** A rename must be documentation at both ends; an empty diff is not a docs-only update. */
65
+ export function documentationPath(path) {
66
+ return typeof path === "string" && !path.split("/").some((part) => part === "..") &&
67
+ (/^docs\//i.test(path) || /\.md$/i.test(path) || /(?:^|\/)LICENSE$/i.test(path) || /\.(?:png|jpe?g|gif|webp|svg|ico|avif|bmp|tiff?)$/i.test(path))
68
+ }
69
+
70
+ export function docsOnlyFiles(files) {
71
+ return Array.isArray(files) && files.length > 0 && files.every((file) =>
72
+ documentationPath(file.filename) && (!file.previous_filename || documentationPath(file.previous_filename)))
73
+ }
74
+
75
+ /** Select a dated validation record, never a parent guessed from Git history. */
76
+ export function previousValidatedCommit(registry, report, catalog = null) {
77
+ if (!repositorySlug(report.plugin.repository)) return null
78
+ const sources = Array.isArray(registry?.sources) ? registry.sources : Object.values(registry?.sources || {})
79
+ const source = sources.find((entry) => repositorySlug(entry.repo) === repositorySlug(report.plugin.repository))
80
+ if (!report.validated?.commit) return null
81
+ const target = report.validated.commit
82
+ const checkedAt = Date.parse(report.validated.checkedAt)
83
+ const candidates = [...(Array.isArray(source?.listingValidationHistory) ? source.listingValidationHistory : []), {
84
+ commit: source?.listingValidatedCommit, validatedAt: source?.listingValidatedAt,
85
+ }, { commit: report.previousValidated?.commit, validatedAt: report.previousValidated?.checkedAt }]
86
+ // The catalog also records successful upstream validation before an update
87
+ // is promoted to the listing. This is validation evidence, not branch HEAD.
88
+ for (const plugin of Array.isArray(catalog?.plugins) ? catalog.plugins : []) {
89
+ if (repositorySlug(plugin.repo) === repositorySlug(report.plugin.repository)) {
90
+ candidates.push({ commit: plugin.upstreamValidatedCommit, validatedAt: plugin.upstreamValidatedAt })
91
+ }
92
+ }
93
+ return candidates.filter((entry) => /^[a-f0-9]{40}$/i.test(entry.commit || "") && entry.commit.toLowerCase() !== target.toLowerCase() &&
94
+ Number.isFinite(Date.parse(entry.validatedAt)) && Date.parse(entry.validatedAt) <= checkedAt)
95
+ .sort((a, b) => Date.parse(a.validatedAt) - Date.parse(b.validatedAt)).at(-1)?.commit.toLowerCase() || null
96
+ }
97
+
98
+ export async function validatedDocumentationDiff({ report, registry, catalog, registryReason = null, compare = compareCommits }) {
99
+ const previous = previousValidatedCommit(registry, report, catalog)
100
+ if (!previous) return { previousCommit: null, validatedCommit: report.validated?.commit || null, docsOnly: null, files: null, source: null, reason: registryReason || "previous validated commit unknown in the marketplace registry" }
101
+ try {
102
+ const diff = await compare(report.plugin.repository, previous, report.validated.commit)
103
+ return { previousCommit: previous, validatedCommit: report.validated.commit, docsOnly: docsOnlyFiles(diff.files), files: diff.files.length, source: diff.url, reason: null }
104
+ } catch (error) {
105
+ return { previousCommit: previous, validatedCommit: report.validated.commit, docsOnly: null, files: null, source: null, reason: `${error.code || "compare-unavailable"}: ${error.message}` }
106
+ }
107
+ }
@@ -115,9 +115,8 @@ export function code(role) {
115
115
  // --- geometry ---------------------------------------------------------------
116
116
 
117
117
  /**
118
- * The width everything is composed for. Eighty columns is the contract: a
119
- * check's why-paragraph, a remedy, a refusal and the help all wrap inside it,
120
- * and tests/unit/style.test.mjs renders every report and measures. The one
118
+ * The stable width for pipes and files. Terminals use their current column
119
+ * count, up to MAX_COLUMNS so wide windows still have readable prose. The one
121
120
  * thing exempt is the marketplace's own baseline report, which is printed
122
121
  * verbatim because rewrapping somebody else's attestation would be editing it.
123
122
  *
@@ -125,6 +124,23 @@ export function code(role) {
125
124
  * `overflows` below is the measurement, and it is the one both test files use.
126
125
  */
127
126
  export const COLUMNS = 80
127
+ export const MAX_COLUMNS = 120
128
+
129
+ let compositionStream = null
130
+
131
+ /** Resolve at rendering time, so a resized terminal's next output uses its new width. */
132
+ export function outputColumns(stream = compositionStream || process.stdout) {
133
+ return stream?.isTTY && Number.isFinite(stream.columns) && stream.columns >= 1
134
+ ? Math.min(MAX_COLUMNS, Math.floor(stream.columns))
135
+ : COLUMNS
136
+ }
137
+
138
+ /** Scope synchronous composition to its destination, including files and stderr. */
139
+ export function withOutputStream(stream, render) {
140
+ const previous = compositionStream
141
+ compositionStream = stream
142
+ try { return render() } finally { compositionStream = previous }
143
+ }
128
144
 
129
145
  /**
130
146
  * The indent scale, in columns. Three stops, and every line in the tool starts
@@ -327,7 +343,7 @@ export function overflows(line, total = COLUMNS) {
327
343
  // --- composition ------------------------------------------------------------
328
344
 
329
345
  /**
330
- * Wrap prose to the contract width, and paint it.
346
+ * Wrap prose to the destination width, and paint it.
331
347
  *
332
348
  * `indent` is the column the text starts in and is part of the width, which is
333
349
  * the bug the earlier version had: it wrapped at 78 and then indented by 7, and
@@ -346,7 +362,7 @@ export function overflows(line, total = COLUMNS) {
346
362
  * @param {(name: string, text: string) => string} [c]
347
363
  * @returns {string[]} lines, indented and painted
348
364
  */
349
- export function wrap(text, { indent = 0, width: total = COLUMNS, first = indent } = {}, c = styler(false)) {
365
+ export function wrap(text, { indent = 0, width: total = outputColumns(), first = indent } = {}, c = styler(false)) {
350
366
  // A word is a run of non-spaces, or a backticked span with whatever
351
367
  // punctuation clings to it: "(`omakit pin`)." is one word.
352
368
  const words = String(text).match(/[^\s`]*`[^`]*`[^\s`]*|\S+/g) || []
@@ -422,7 +438,7 @@ export function continuation(value, c) {
422
438
  * under itself. There is exactly one of these under any failure, and it is the
423
439
  * only line in the tool that starts with an arrow, so it can be found by shape.
424
440
  */
425
- export function action(text, c, { indent = GUTTER, width: total = COLUMNS } = {}) {
441
+ export function action(text, c, { indent = GUTTER, width: total = outputColumns() } = {}) {
426
442
  // Painted after wrapping, and all of it cyan: the whole line is the thing
427
443
  // to do, so a backticked word inside it has nothing to stand out from.
428
444
  const lines = wrap(text, { indent: indent + 2, width: total })
@@ -449,11 +465,11 @@ export function labelled(label, text, c, { indent = GUTTER } = {}) {
449
465
  * grey. It is the same shape the wordmark uses (the name, then its rule), so a
450
466
  * section of a report and the front door of the tool are drawn by one idea.
451
467
  */
452
- export function section(title, c, { width: total = COLUMNS } = {}) {
468
+ export function section(title, c, { width: total = outputColumns() } = {}) {
453
469
  return [c("heading", title), c("punctuation", DENSITY.floor.repeat(total))]
454
470
  }
455
471
 
456
472
  /** A rule with no heading, the same floor, for a wordmark or a block that names itself. */
457
- export function rule(c, { width: total = COLUMNS, tint = "punctuation" } = {}) {
473
+ export function rule(c, { width: total = outputColumns(), tint = "punctuation" } = {}) {
458
474
  return c(tint, DENSITY.floor.repeat(total))
459
475
  }
@@ -23,6 +23,7 @@ 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 { openIssuesForRepository, reviewCostVerdict, reviewPolicy } from "./review-cost.mjs"
26
27
 
27
28
  /**
28
29
  * One arrow per cause, in this order, so a person fixes the thing that is
@@ -135,6 +136,7 @@ export function reproduceCommand({ target, category, tags, pluginName, notes, su
135
136
  * @param {{ repoRoot: string, target: string, category?: string, tags?: string|string[],
136
137
  * notes?: string, suggestedTag?: string, pluginName?: string,
137
138
  * allowDirty?: boolean, offline?: boolean,
139
+ * github?: object,
138
140
  * readRegistry?: typeof liveRegistry,
139
141
  * chooser?: (question: { contract: object, defaults: object, missing: string[] }) => Promise<{ category?: string, tags?: string[] }> }} options
140
142
  * `readRegistry` is injectable for tests; the default reads the marketplace's
@@ -404,7 +406,7 @@ export async function submitPreflight(options) {
404
406
  if (!options.offline && subject.repository.url) {
405
407
  phase("reading the repository's default-branch HEAD")
406
408
  try {
407
- head = await defaultBranchHead(subject.repository.url)
409
+ head = await (options.github?.defaultBranchHead || defaultBranchHead)(subject.repository.url)
408
410
  } catch (error) {
409
411
  headError = { code: error.code || "head-unreadable", message: error.message }
410
412
  }
@@ -412,9 +414,14 @@ export async function submitPreflight(options) {
412
414
  const validationMatches = head ? head.commit === subject.commit.toLowerCase() : null
413
415
  checks.push(check("submission.validation-commit", {
414
416
  source: "omakit",
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.",
417
+ why: "The marketplace validates the default-branch HEAD it resolves when the issue is opened or edited, not the commit checked here. M6 on 2026-09-15 found 326/519 readable author-fixes comparisons stale (62.8%), with 64 of 583 issues unknown, 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.",
416
418
  severity: options.offline ? "advisory" : "blocking",
417
419
  skipped: options.offline === true,
420
+ // No origin, no URL to read a HEAD from: the check waits on the one that
421
+ // says so. Measured on 0.4.1: it failed as a second root cause with
422
+ // "could not read the default-branch HEAD (unknown): " for a read that
423
+ // was never attempted.
424
+ waitedOn: [!options.offline && !subject.repository.url && "submission.repository-url"],
418
425
  verdict: validationMatches === true,
419
426
  detail: options.offline
420
427
  ? `not checked (--offline). Local commit ${subject.commit}.`
@@ -461,6 +468,14 @@ export async function submitPreflight(options) {
461
468
  : null,
462
469
  }))
463
470
 
471
+ const policy = await reviewPolicy(repoRoot)
472
+ const review = reviewCostVerdict({ baseline: consequence, policy,
473
+ openIssues: consequence?.outcome === policy.manual ? await openIssuesForRepository({ repoRoot, repository: subject.repository.url,
474
+ offline: options.offline === true, github: options.github }) : { count: null, reason: consequence?.outcome === policy.automated ? "open issue count not checked: automated baseline" : preflight.skipReason || preflight.refusal?.message || "baseline outcome needs findings resolved" },
475
+ why: `MEASUREMENTS.md M4: ${figure(figures.outcomes[policy.manual] || 0)} of ${figure(figures.withBaseline)} recorded listing baselines required review at the pin. M9: on 2026-09-15, 140 of 307 open update issues carried the manual-review label; 4 of 139 compared validated diffs were docs-only, with 1 unavailable. The baseline scans the whole snapshot, not the update diff, so unchanged capabilities also require another review. Sources and exact marketplace HEAD are recorded in MEASUREMENTS.md.`,
476
+ })
477
+ checks.push(review.check)
478
+
464
479
  const blocking = checks.filter((entry) => entry.severity === "blocking" && entry.verdict === "fail")
465
480
  const advisory = checks.filter((entry) => entry.severity === "advisory" && entry.verdict === "fail")
466
481
  const unknown = checks.filter((entry) => entry.verdict === "unknown")
@@ -511,6 +526,7 @@ export async function submitPreflight(options) {
511
526
  offline: options.offline === true,
512
527
  }),
513
528
  checks,
529
+ reviewCost: review.reviewCost,
514
530
  outcome,
515
531
  ready,
516
532
  listing,
@@ -7,7 +7,7 @@
7
7
 
8
8
  import { UNAUTHENTICATED_LIMIT } from "./github.mjs"
9
9
  import { MARKETPLACE_PIN } from "./pin.mjs"
10
- import { colourEnabled, paintProse, STEP, styler } from "./style.mjs"
10
+ import { colourEnabled, paintProse, STEP, styler, withOutputStream, wrap } from "./style.mjs"
11
11
 
12
12
  /**
13
13
  * "Safe" means one thing, everywhere it appears: this runs on your own
@@ -208,16 +208,40 @@ export function paintSignature(signature, c) {
208
208
  *
209
209
  * @param {{ colour?: boolean, heading?: boolean }} [options]
210
210
  */
211
- export function renderSummary({ colour = colourEnabled(), heading = true } = {}) {
212
- const c = styler(colour)
213
- const out = heading ? [`${c("typeable.bold", "omakit")}${c("punctuation", ":")} ${TAGLINE}`, ""] : []
214
- for (const command of COMMANDS) {
215
- out.push(`${INDENT}${paintSignature([].concat(command.signature)[0], c)}`)
211
+ function commandSignatures(command) {
212
+ const signatures = []
213
+ for (const line of [].concat(command.signature)) {
214
+ if (line.startsWith("omakit ")) signatures.push(line)
215
+ else signatures[signatures.length - 1] += ` ${line}`
216
216
  }
217
- out.push("")
218
- out.push(`${INDENT}${paintProse("`omakit help` is the same list with what each command does, and", c)}`)
219
- out.push(`${INDENT}${paintProse("what it reads. `omakit setup` is the one to run first.", c)}`)
220
- return `${out.join("\n")}\n`
217
+ return signatures
218
+ }
219
+
220
+ function signatureLines(signature, c) {
221
+ // A placeholder or bracketed optional argument stays whole, like the
222
+ // backticked typeable spans used by the shared wrapper.
223
+ const grouped = signature.replace(/\[[^\]]+\]|<[^>]+>(?:@<[^>]+>)?/g, (part) => `\`${part}\``)
224
+ return wrap(grouped, { indent: STEP * 3, first: STEP }).map((line) => paintSignature(line, c))
225
+ }
226
+
227
+ export function renderSummary({ stream = process.stdout, colour = colourEnabled(stream), heading = true } = {}) {
228
+ return withOutputStream(stream, () => {
229
+ const c = styler(colour)
230
+ const out = heading ? [`${c("typeable.bold", "omakit")}${c("punctuation", ":")} ${TAGLINE}`, ""] : []
231
+ if (stream.isTTY && heading) out.splice(0, 1, ...wrap(out[0]))
232
+ for (const command of COMMANDS) {
233
+ if (stream.isTTY) out.push(...signatureLines([].concat(command.signature)[0], c))
234
+ else out.push(`${INDENT}${paintSignature([].concat(command.signature)[0], c)}`)
235
+ }
236
+ out.push("")
237
+ if (stream.isTTY) {
238
+ out.push(...wrap("`omakit help` is the same list with what each command does, and what it reads. `omakit setup` is the one to run first.", { indent: STEP }, c))
239
+ } else {
240
+ out.push(`${INDENT}${paintProse("`omakit help` is the same list with what each command does, and", c)}`)
241
+ out.push(`${INDENT}${paintProse("what it reads. `omakit setup` is the one to run first.", c)}`)
242
+ }
243
+ return `${out.join("\n")}\n`
244
+ })
221
245
  }
222
246
 
223
247
  /**
@@ -225,23 +249,29 @@ export function renderSummary({ colour = colourEnabled(), heading = true } = {})
225
249
  * when the line above has already named the tool, as under an unknown
226
250
  * command, so the same sentence is not printed twice.
227
251
  */
228
- export function renderUsage({ colour = colourEnabled(), heading = true } = {}) {
229
- const c = styler(colour)
230
- const out = heading ? [`${c("typeable.bold", "omakit")}${c("punctuation", ":")} ${TAGLINE}`, ""] : []
252
+ export function renderUsage({ stream = process.stdout, colour = colourEnabled(stream), heading = true } = {}) {
253
+ return withOutputStream(stream, () => {
254
+ const c = styler(colour)
255
+ const out = heading ? [`${c("typeable.bold", "omakit")}${c("punctuation", ":")} ${TAGLINE}`, ""] : []
256
+ if (stream.isTTY && heading) out.splice(0, 1, ...wrap(out[0]))
231
257
 
232
- for (const command of COMMANDS) {
233
- for (const line of [].concat(command.signature)) {
234
- out.push(`${INDENT}${paintSignature(line, c)}`)
235
- }
236
- for (const line of command.lines) {
237
- out.push(`${DESCRIPTION}${paintProse(line, c)}`)
258
+ for (const command of COMMANDS) {
259
+ if (stream.isTTY) {
260
+ for (const signature of commandSignatures(command)) out.push(...signatureLines(signature, c))
261
+ out.push(...wrap(command.lines.join(" "), { indent: STEP * 3 }, c))
262
+ } else {
263
+ for (const line of [].concat(command.signature)) out.push(`${INDENT}${paintSignature(line, c)}`)
264
+ for (const line of command.lines) out.push(`${DESCRIPTION}${paintProse(line, c)}`)
265
+ }
266
+ out.push("")
238
267
  }
239
- out.push("")
240
- }
241
268
 
242
- out.push(`${INDENT}${paintSignature(TARGET_NOTE, c)}`)
243
- out.push("")
244
- out.push(c("heading", "GitHub access:"))
245
- for (const line of AUTHENTICATION) out.push(`${INDENT}${paintProse(line, c)}`)
246
- return `${out.join("\n")}\n`
269
+ if (stream.isTTY) out.push(...signatureLines(TARGET_NOTE, c))
270
+ else out.push(`${INDENT}${paintSignature(TARGET_NOTE, c)}`)
271
+ out.push("")
272
+ out.push(c("heading", "GitHub access:"))
273
+ if (stream.isTTY) out.push(...wrap(AUTHENTICATION.join(" "), { indent: STEP }, c))
274
+ else for (const line of AUTHENTICATION) out.push(`${INDENT}${paintProse(line, c)}`)
275
+ return `${out.join("\n")}\n`
276
+ })
247
277
  }
@@ -9,19 +9,11 @@
9
9
  // compares branch HEADs only for repositories that are already listed. So
10
10
  // pushing a fix does nothing, and commenting "fixed in abc123" does nothing.
11
11
  //
12
- // Measured reason (docs/MEASUREMENTS.md M6): of the 464 submissions parked in
13
- // the author's court, 73% have a default-branch HEAD ahead of the validated
14
- // commit. 47% pushed after the maintainer's review without the marketplace ever
15
- // seeing it, and 82% of those authors also commented, so they are engaged and
16
- // stuck rather than gone. Of 13 open submissions inspected with no labels left,
17
- // 9 had passed validation and passed the automated security baseline with zero
18
- // findings and were blocked solely because their validated commit had fallen
19
- // behind while they waited. 46% of the maintainer's own requests for a fresh validation never
20
- // produced one; in the parked group 77% never did. The instruction that would
21
- // fix this appears 22 times in the failure path of
22
- // `scripts/submission-feedback.mjs` and zero times in the success path of
23
- // `scripts/validate-submission.mjs`, which is the path 97 of 100 parked
24
- // submissions took.
12
+ // Measured reason (docs/MEASUREMENTS.md M6), 2026-09-15: 326 of 519 readable
13
+ // author-fixes comparisons were stale (62.8%), with 64 of 583 issues unknown.
14
+ // The original 2026-09-12 research sampled 100 of a 464-issue queue: 68 of
15
+ // 93 readable pairs were stale (73.1%). It did not measure every queue item.
16
+ // Editing the issue body is the action the pinned workflow observes.
25
17
  //
26
18
  // This command reads. It never edits the issue, never comments, never labels.
27
19
  // The action it names is the author's to take.
@@ -30,6 +22,8 @@ import { join } from "node:path"
30
22
  import { pathToFileURL } from "node:url"
31
23
  import { MARKETPLACE_PIN, requirePin } from "./pin.mjs"
32
24
  import { authenticatedUser, repositoryIssues, defaultBranchHead, issue, issueComments, parseIssueUrl, token, GitHubError } from "./github.mjs"
25
+ import { liveRegistry } from "./registry.mjs"
26
+ import { reviewPolicy, validatedDocumentationDiff } from "./review-cost.mjs"
33
27
 
34
28
  export class WatchError extends Error {
35
29
  constructor(code, message) {
@@ -77,8 +71,11 @@ export async function discoverWatchIssues({ user, github = {}, onPhase = () => {
77
71
  }
78
72
 
79
73
  /** A batch keeps independent read failures visible and shares repository HEAD reads. */
80
- export async function validationWatchAll({ repoRoot, discovery, github = {}, onPhase = () => {} }) {
74
+ export async function validationWatchAll({ repoRoot, discovery, github = {}, readRegistry = liveRegistry, onPhase = () => {} }) {
81
75
  if (discovery.issues.length) requirePin(repoRoot)
76
+ const policy = discovery.issues.length ? await reviewPolicy(repoRoot) : null
77
+ let registry = null
78
+ const reviewCostSummary = { pluginUpdates: 0, manualQueue: 0, docsOnly: 0, compared: 0, skipped: [] }
82
79
  const heads = new Map()
83
80
  const readHead = github.defaultBranchHead || defaultBranchHead
84
81
  const shared = { ...github, defaultBranchHead: (url) => {
@@ -102,7 +99,38 @@ export async function validationWatchAll({ repoRoot, discovery, github = {}, onP
102
99
  await Promise.all(Array.from({ length: Math.min(4, discovery.issues.length) }, worker))
103
100
  const summary = { total: results.length, current: 0, stale: 0, unknown: 0 }
104
101
  for (const result of results) summary[result.report?.verdict.state || "unknown"] += 1
105
- return { mode: "all", account: discovery.account, marketplace: discovery.marketplace, summary, issues: results }
102
+ // M9: count labels on the listed issues, then compare only updates in the manual queue.
103
+ const manual = results.filter((row) => {
104
+ const labels = row.report?.read.labels || row.issue.labels
105
+ if (!labels.includes(policy?.updateLabel)) return false
106
+ reviewCostSummary.pluginUpdates += 1
107
+ return labels.includes(policy.reviewLabel)
108
+ })
109
+ reviewCostSummary.manualQueue = manual.length
110
+ if (manual.length) {
111
+ onPhase("reading the previous validated marketplace snapshots")
112
+ try { registry = await readRegistry({ repoRoot }) } catch (error) { registry = { reason: error.message } }
113
+ }
114
+ let compareNext = 0
115
+ async function compareWorker() {
116
+ while (compareNext < manual.length) {
117
+ const row = manual[compareNext++]
118
+ const diff = row.report
119
+ ? await validatedDocumentationDiff({ report: row.report, registry: registry?.source === "head" ? registry.registry : null,
120
+ catalog: registry?.source === "head" ? registry.catalog : null,
121
+ registryReason: registry?.source === "head" ? null : `previous validated commit unknown: live registry unavailable (${registry?.reason || "unknown"})`, compare: github.compareCommits })
122
+ : { docsOnly: null, reason: `issue unreadable: ${row.error?.message || "unknown"}` }
123
+ row.documentationDiff = diff
124
+ if (diff.docsOnly === null) reviewCostSummary.skipped.push({ issue: row.issue.number, reason: diff.reason })
125
+ else {
126
+ reviewCostSummary.compared += 1
127
+ if (diff.docsOnly) reviewCostSummary.docsOnly += 1
128
+ }
129
+ }
130
+ }
131
+ await Promise.all(Array.from({ length: Math.min(4, manual.length) }, compareWorker))
132
+ reviewCostSummary.skipped.sort((a, b) => a.issue - b.issue)
133
+ return { mode: "all", account: discovery.account, marketplace: discovery.marketplace, summary, reviewCostSummary, issues: results }
106
134
  }
107
135
 
108
136
  // The one action that re-runs validation, in the register the marketplace itself
@@ -133,7 +161,7 @@ async function loadVerification(pinDir) {
133
161
  * happens to recognise. So each form is read by the parser the marketplace
134
162
  * itself uses for it, and the issue says which one it is.
135
163
  */
136
- async function repositoryFor(pinDir, subject) {
164
+ export async function repositoryFor(pinDir, subject) {
137
165
  const submission = await loadSubmission(pinDir)
138
166
  const verification = await loadVerification(pinDir)
139
167
  const title = String(subject.title || "")
@@ -221,11 +249,29 @@ export async function validationWatch({ repoRoot, issueUrl, onPhase, github = {}
221
249
  baselineError = { code: error.code || "baseline-unreadable", message: error.message }
222
250
  }
223
251
  const fallback = validated ? null : validationCommentCommit(comments)
252
+ let previousValidated = null
253
+ if (validated) {
254
+ // Revalidation on an existing issue may precede the latest marker. Ignore
255
+ // repeated attestations of the same commit, not a different validated tree.
256
+ for (const comment of [...comments].reverse()) {
257
+ try {
258
+ const marker = record.findLatestSecurityBaseline([comment])
259
+ if (marker && marker.commitSha !== validated.commit && Date.parse(marker.checkedAt) <= Date.parse(validated.checkedAt)) {
260
+ previousValidated = { commit: marker.commitSha, checkedAt: marker.checkedAt, source: "security-baseline-marker" }
261
+ break
262
+ }
263
+ } catch { /* An incomplete run is not a previously validated commit. */ }
264
+ }
265
+ }
224
266
 
225
267
  const labels = (subject.labels || []).map((label) => (typeof label === "string" ? label : label?.name)).filter(Boolean)
226
268
  const authorComments = comments.filter((comment) => comment?.user?.login === subject.user?.login)
269
+ // The discussion is a person's: the marketplace's own bot and any other
270
+ // automation (GitHub marks an app's account `type: "Bot"`, and names it
271
+ // `<app>[bot]`) is neither a reviewer nor the last human review.
272
+ const isBot = (user) => user?.type === "Bot" || /\[bot\]$/i.test(String(user?.login || ""))
227
273
  const maintainerComments = comments.filter(
228
- (comment) => comment?.user?.login && comment.user.login !== subject.user?.login && comment.user.login !== "github-actions[bot]",
274
+ (comment) => comment?.user?.login && comment.user.login !== subject.user?.login && !isBot(comment.user),
229
275
  )
230
276
 
231
277
  let head = null
@@ -267,6 +313,7 @@ export async function validationWatch({ repoRoot, issueUrl, onPhase, github = {}
267
313
  },
268
314
  plugin: { repository: repositoryUrl, repositoryError, form: issueKind },
269
315
  validated,
316
+ previousValidated,
270
317
  validationCommentFallback: fallback,
271
318
  baselineError,
272
319
  head,