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.
- package/README.md +29 -91
- package/package.json +1 -1
- package/skills/omarchy-plugin-check/SKILL.md +2 -2
- package/skills/omarchy-plugin-submit/SKILL.md +2 -2
- package/skills/omarchy-plugin-validation-watch/SKILL.md +4 -4
- package/tools/marketplace/README.md +7 -2
- package/tools/marketplace/ask.mjs +4 -4
- package/tools/marketplace/banner.mjs +10 -1
- package/tools/marketplace/cli.mjs +60 -26
- package/tools/marketplace/github.mjs +13 -2
- package/tools/marketplace/measure-review-cost.mjs +39 -0
- package/tools/marketplace/measure-staleness.mjs +76 -0
- package/tools/marketplace/report.mjs +15 -4
- package/tools/marketplace/review-cost.mjs +107 -0
- package/tools/marketplace/style.mjs +24 -8
- package/tools/marketplace/submit.mjs +18 -2
- package/tools/marketplace/usage.mjs +56 -26
- package/tools/marketplace/watch.mjs +64 -17
- package/tools/weigh/audit.mjs +21 -3
- package/tools/weigh/confirm.mjs +3 -3
|
@@ -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,
|
|
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
|
|
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 =
|
|
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
|
|
119
|
-
*
|
|
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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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.
|
|
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
|
-
|
|
212
|
-
const
|
|
213
|
-
const
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
230
|
-
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
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
|
-
|
|
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
|
|
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,
|