omakit 0.1.0

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.
Files changed (38) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +229 -0
  3. package/bin/omakit +3 -0
  4. package/package.json +40 -0
  5. package/skills/omarchy-plugin-submit/SKILL.md +87 -0
  6. package/skills/omarchy-plugin-validation-watch/SKILL.md +62 -0
  7. package/tests/parity/corpus.mjs +62 -0
  8. package/tests/parity/run.mjs +194 -0
  9. package/tools/marketplace/README.md +64 -0
  10. package/tools/marketplace/agent-control.mjs +72 -0
  11. package/tools/marketplace/banner.mjs +307 -0
  12. package/tools/marketplace/cli.mjs +283 -0
  13. package/tools/marketplace/completion.mjs +263 -0
  14. package/tools/marketplace/doctor.mjs +156 -0
  15. package/tools/marketplace/effect.mjs +115 -0
  16. package/tools/marketplace/form.mjs +209 -0
  17. package/tools/marketplace/github.mjs +206 -0
  18. package/tools/marketplace/issue.mjs +80 -0
  19. package/tools/marketplace/local-transport.mjs +162 -0
  20. package/tools/marketplace/parity-output.mjs +26 -0
  21. package/tools/marketplace/paths.mjs +10 -0
  22. package/tools/marketplace/pin.mjs +203 -0
  23. package/tools/marketplace/plugin.mjs +64 -0
  24. package/tools/marketplace/preflight.mjs +164 -0
  25. package/tools/marketplace/progress.mjs +96 -0
  26. package/tools/marketplace/registry.mjs +216 -0
  27. package/tools/marketplace/report.mjs +192 -0
  28. package/tools/marketplace/run-baseline.mjs +72 -0
  29. package/tools/marketplace/setup.mjs +136 -0
  30. package/tools/marketplace/style.mjs +443 -0
  31. package/tools/marketplace/submit.mjs +341 -0
  32. package/tools/marketplace/tree.mjs +50 -0
  33. package/tools/marketplace/upgrade.mjs +179 -0
  34. package/tools/marketplace/usage.mjs +191 -0
  35. package/tools/marketplace/verify.mjs +70 -0
  36. package/tools/marketplace/watch.mjs +262 -0
  37. package/tools/marketplace/yaml.mjs +164 -0
  38. package/tools/subject/resolve.mjs +124 -0
@@ -0,0 +1,191 @@
1
+ // The help text, as data rather than one painted string.
2
+ //
3
+ // Kept as structure so it can be coloured without pattern-matching a paragraph,
4
+ // and so the same words serve a terminal and a pipe. A signature is coloured by
5
+ // token: what you type is cyan, what you replace is yellow, the brackets that
6
+ // merely group them stay out of the way.
7
+
8
+ import { UNAUTHENTICATED_LIMIT } from "./github.mjs"
9
+ import { MARKETPLACE_PIN } from "./pin.mjs"
10
+ import { colourEnabled, paintProse, STEP, styler } from "./style.mjs"
11
+
12
+ /**
13
+ * "Safe" means one thing, everywhere it appears: this runs on your own
14
+ * machine, posts nothing, opens no issue and spends nobody's attention. It is
15
+ * never a claim about the security of a plugin or a submission; the baseline's
16
+ * outcome is reported verbatim and is never restated as one.
17
+ */
18
+ export const TAGLINE = "the safe place to find out"
19
+
20
+ /** The shells `omakit setup` installs tab completion for; completion.mjs holds the scripts. */
21
+ export const COMPLETION_SHELLS = Object.freeze(["bash", "zsh", "fish"])
22
+
23
+ export const COMMANDS = Object.freeze([
24
+ {
25
+ signature: "omakit setup",
26
+ lines: [
27
+ "First run, in one command: check the environment, fetch the pinned",
28
+ "marketplace checkout, and say what to try first. Idempotent.",
29
+ ],
30
+ },
31
+ {
32
+ signature: "omakit pin",
33
+ lines: [
34
+ "Fetch or verify the pinned marketplace checkout in the user cache:",
35
+ "$XDG_CACHE_HOME/omakit/marketplace, or ~/.cache/omakit/marketplace.",
36
+ `Read-only, exact commit ${MARKETPLACE_PIN.commit}.`,
37
+ ],
38
+ },
39
+ {
40
+ signature: [
41
+ "omakit submit <target> --category <c> --tags <a,b> [--notes <text>]",
42
+ " [--suggest-tag <t>] [--name <n>] [--offline]",
43
+ " [--allow-dirty] [--json] [--out <file>]",
44
+ ],
45
+ lines: [
46
+ "Every check that is knowable before submitting, the resolved commit, and",
47
+ "the exact issue title and body. Prints them. Never posts anything.",
48
+ ],
49
+ },
50
+ {
51
+ signature: "omakit watch <issue-url> [--json]",
52
+ lines: [
53
+ "Compare the commit the marketplace validated on a submission issue with",
54
+ "the plugin repository's current default-branch HEAD, and say what makes",
55
+ "it validate a newer one. Read-only.",
56
+ ],
57
+ },
58
+ {
59
+ signature: "omakit verify <target> [--allow-dirty] [--out <file>]",
60
+ lines: [
61
+ "The official marketplace security baseline over the local Git transport,",
62
+ "reported verbatim beside the pin identity.",
63
+ ],
64
+ },
65
+ {
66
+ signature: "omakit help --agent",
67
+ lines: [
68
+ "The operating instructions for a coding agent, printed from skills/, so an",
69
+ "agent can read the contract out of the tool instead of the repository.",
70
+ ],
71
+ },
72
+ {
73
+ signature: "omakit upgrade [--dry-run]",
74
+ lines: [
75
+ "Fast-forward this checkout of omakit itself. Refuses a dirty tree, an",
76
+ "unexpected remote and anything that is not a fast-forward. Never moves",
77
+ "the marketplace pin.",
78
+ ],
79
+ },
80
+ {
81
+ signature: "omakit doctor [--offline] [--json]",
82
+ lines: [
83
+ "What is installed, what is pinned, and what has moved since. Reads and",
84
+ "prints; it installs nothing and never moves the pin.",
85
+ ],
86
+ },
87
+ {
88
+ signature: "omakit parity [--count <n>] [--offset <n>] [--out <file>]",
89
+ lines: [
90
+ "The official baseline over GitHub versus the local transport on real",
91
+ "listed repositories; a packaged install requires --out for evidence.",
92
+ ],
93
+ },
94
+ ])
95
+
96
+ // A command sits one STEP in from the heading; what it does sits one STEP in
97
+ // from the command's name, which starts after "omakit ".
98
+ const INDENT = " ".repeat(STEP)
99
+ const DESCRIPTION = " ".repeat(STEP * 3)
100
+
101
+ export const TARGET_NOTE = "<target> is a local Git repository path, or <https url>@<40-char sha>."
102
+
103
+ /**
104
+ * The answer to "what do I have to set up?" is "nothing", and it is said in
105
+ * so many words. There is no environment section because omakit reads no
106
+ * credential variable of its own: `gh` is the one credential source and honours
107
+ * GH_TOKEN and GITHUB_TOKEN itself, the pin follows XDG unless explicitly overridden, and
108
+ * the terminal's own conventions (a pipe, TERM=dumb, NO_COLOR) are what turn
109
+ * colour and motion off.
110
+ */
111
+ export const AUTHENTICATION = Object.freeze([
112
+ "Read-only, and optional. omakit uses your `gh` login if you have one, and",
113
+ "otherwise goes unauthenticated. `submit` and `verify` need no network at",
114
+ `all; \`watch\` and \`parity\` are capped at ${UNAUTHENTICATED_LIMIT} requests an hour without a`,
115
+ "login. omakit never writes a credential anywhere.",
116
+ ])
117
+
118
+ /**
119
+ * Colour a signature by token: what you type is cyan, what you replace is
120
+ * yellow, the subcommand is bold because that is the word you are scanning for,
121
+ * and the brackets that merely group things stay out of the way.
122
+ *
123
+ * One pass, deliberately. Two passes would let the second one find the escape
124
+ * sequences the first inserted and colour the `[` inside them, which corrupts
125
+ * every sequence downstream of it.
126
+ */
127
+ const TOKEN = /(^\s*omakit +[a-z][a-z-]*)|(<[^>]+>)|(--[a-z-]+)|(\bomakit\b)|(\b[a-z]+(?:\|[a-z]+)+\b)|([[\]])/g
128
+
129
+ export function paintSignature(signature, c) {
130
+ return signature.replace(TOKEN, (token, lead, placeholder, flag, bare, choice, bracket) => {
131
+ if (lead) {
132
+ const [, indent, name, gap, subcommand] = lead.match(/^(\s*)(omakit)( +)([a-z][a-z-]*)$/)
133
+ return `${indent}${c("typeable.bold", name)}${gap}${c("name", subcommand)}`
134
+ }
135
+ if (placeholder) return c("placeholder", placeholder)
136
+ if (flag) return c("typeable", flag)
137
+ if (bare) return c("typeable.bold", bare)
138
+ // A choice like bash|zsh|fish: each word is one you could type, and the
139
+ // bar between them is grouping.
140
+ if (choice) return choice.split("|").map((word) => c("typeable", word)).join(c("punctuation", "|"))
141
+ return c("punctuation", bracket)
142
+ })
143
+ }
144
+
145
+ /**
146
+ * The front door: the commands and nothing else.
147
+ *
148
+ * The measured reason this exists. The full reference is 53 lines and a
149
+ * terminal is not 60 rows tall, so a bare `omakit` scrolled its own first
150
+ * lines off the top of the screen before anyone could read them. A list of
151
+ * what you can run fits, and the reference is one command away.
152
+ *
153
+ * @param {{ colour?: boolean, heading?: boolean }} [options]
154
+ */
155
+ export function renderSummary({ colour = colourEnabled(), heading = true } = {}) {
156
+ const c = styler(colour)
157
+ const out = heading ? [`${c("typeable.bold", "omakit")}${c("punctuation", ":")} ${TAGLINE}`, ""] : []
158
+ for (const command of COMMANDS) {
159
+ out.push(`${INDENT}${paintSignature([].concat(command.signature)[0], c)}`)
160
+ }
161
+ out.push("")
162
+ out.push(`${INDENT}${paintProse("`omakit help` is the same list with what each command does, and", c)}`)
163
+ out.push(`${INDENT}${paintProse("what it reads. `omakit setup` is the one to run first.", c)}`)
164
+ return `${out.join("\n")}\n`
165
+ }
166
+
167
+ /**
168
+ * @param {{ colour?: boolean, heading?: boolean }} [options] `heading: false`
169
+ * when the line above has already named the tool, as under an unknown
170
+ * command, so the same sentence is not printed twice.
171
+ */
172
+ export function renderUsage({ colour = colourEnabled(), heading = true } = {}) {
173
+ const c = styler(colour)
174
+ const out = heading ? [`${c("typeable.bold", "omakit")}${c("punctuation", ":")} ${TAGLINE}`, ""] : []
175
+
176
+ for (const command of COMMANDS) {
177
+ for (const line of [].concat(command.signature)) {
178
+ out.push(`${INDENT}${paintSignature(line, c)}`)
179
+ }
180
+ for (const line of command.lines) {
181
+ out.push(`${DESCRIPTION}${paintProse(line, c)}`)
182
+ }
183
+ out.push("")
184
+ }
185
+
186
+ out.push(`${INDENT}${paintSignature(TARGET_NOTE, c)}`)
187
+ out.push("")
188
+ out.push(c("heading", "GitHub access:"))
189
+ for (const line of AUTHENTICATION) out.push(`${INDENT}${paintProse(line, c)}`)
190
+ return `${out.join("\n")}\n`
191
+ }
@@ -0,0 +1,70 @@
1
+ // `omakit verify <target>`: the pinned official baseline over the local Git
2
+ // transport, reported verbatim beside the pin identity. `omakit submit` builds on
3
+ // this; `verify` exists on its own so the raw official result can be inspected
4
+ // without any Omakit check around it.
5
+ import { runBaseline } from "./run-baseline.mjs"
6
+ import { MARKETPLACE_PIN } from "./pin.mjs"
7
+ import { ASSUMED_BY_ADAPTER } from "./local-transport.mjs"
8
+
9
+ const MARKETPLACE_STATEMENT =
10
+ "Official baseline preview over a local snapshot. The marketplace rescans the public commit itself. This is not approval, listing, verification or a security audit."
11
+
12
+ /**
13
+ * @param {{ repoRoot: string, subject: { dir: string, commit: string, repository: { url: string|null } }, listedPlugins?: Array }} options
14
+ * @returns the `marketplaceBaseline` section: pin, transport, adapter
15
+ * assumptions, the official result verbatim, and the statement.
16
+ */
17
+ export async function marketplaceBaselineSection({ repoRoot, subject, listedPlugins }) {
18
+ const pin = {
19
+ repository: MARKETPLACE_PIN.repository,
20
+ commit: MARKETPLACE_PIN.commit,
21
+ baselineVersion: MARKETPLACE_PIN.baselineVersion,
22
+ enforcementMode: MARKETPLACE_PIN.enforcementMode,
23
+ }
24
+ if (!subject.repository.url) {
25
+ return {
26
+ pin,
27
+ transport: "none",
28
+ assumedByAdapter: [],
29
+ invoked: false,
30
+ skipReason: "no declared GitHub repository URL",
31
+ official: null,
32
+ statement: MARKETPLACE_STATEMENT,
33
+ }
34
+ }
35
+ let run
36
+ try {
37
+ run = await runBaseline({
38
+ repoRoot,
39
+ repoUrl: subject.repository.url,
40
+ commitSha: subject.commit,
41
+ transport: "local",
42
+ repoDir: subject.dir,
43
+ listedPlugins,
44
+ })
45
+ } catch (error) {
46
+ if (error && typeof error.code === "string" && error.code.startsWith("security-baseline-")) {
47
+ // The official code refused the snapshot (scan limit, unreadable file):
48
+ // that refusal is the official result and is reported verbatim.
49
+ return {
50
+ pin,
51
+ transport: "local-git",
52
+ assumedByAdapter: [...ASSUMED_BY_ADAPTER],
53
+ invoked: true,
54
+ skipReason: null,
55
+ official: { error: { code: error.code, message: error.message, ...(error.details || {}) } },
56
+ statement: MARKETPLACE_STATEMENT,
57
+ }
58
+ }
59
+ throw error
60
+ }
61
+ return {
62
+ pin: { ...pin, commit: run.pin.commit, baselineVersion: run.pin.baselineVersion, enforcementMode: run.pin.enforcementMode },
63
+ transport: "local-git",
64
+ assumedByAdapter: [...run.adapter.assumedByAdapter],
65
+ invoked: true,
66
+ skipReason: null,
67
+ official: run.result,
68
+ statement: MARKETPLACE_STATEMENT,
69
+ }
70
+ }
@@ -0,0 +1,262 @@
1
+ // The validation watch. This is the reason the tool exists.
2
+ //
3
+ // The marketplace validates one exact commit, and the review that follows is
4
+ // of that commit. The only action that makes it validate a newer one is
5
+ // editing the issue body:
6
+ // `route-issue-automation.yml` is the only workflow with a direct `issues`
7
+ // trigger (`opened, edited, reopened, labeled, unlabeled`), there is no
8
+ // `issue_comment` trigger anywhere in the marketplace, and `refresh-catalog.yml`
9
+ // compares branch HEADs only for repositories that are already listed. So
10
+ // pushing a fix does nothing, and commenting "fixed in abc123" does nothing.
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.
25
+ //
26
+ // This command reads. It never edits the issue, never comments, never labels.
27
+ // The action it names is the author's to take.
28
+
29
+ import { join } from "node:path"
30
+ import { pathToFileURL } from "node:url"
31
+ import { MARKETPLACE_PIN, requirePin } from "./pin.mjs"
32
+ import { defaultBranchHead, issue, issueComments, parseIssueUrl, token, GitHubError } from "./github.mjs"
33
+
34
+ export class WatchError extends Error {
35
+ constructor(code, message) {
36
+ super(message)
37
+ this.name = "WatchError"
38
+ this.code = code
39
+ }
40
+ }
41
+
42
+ const MARKETPLACE_SLUG = MARKETPLACE_PIN.repository.replace(/^https:\/\/github\.com\//, "").toLowerCase()
43
+
44
+ // The one action that re-runs validation, in the register the marketplace itself
45
+ // uses in its own failure feedback.
46
+ export const REFRESH_ACTION =
47
+ "Edit the issue body. That is the only action that re-runs validation and the security baseline against a new commit: a push does not, and a comment does not."
48
+
49
+ async function loadRecord(pinDir) {
50
+ return import(pathToFileURL(join(pinDir, "scripts/security-baseline-record.mjs")).href)
51
+ }
52
+
53
+ async function loadSubmission(pinDir) {
54
+ return import(pathToFileURL(join(pinDir, "scripts/submission.mjs")).href)
55
+ }
56
+
57
+ async function loadVerification(pinDir) {
58
+ return import(pathToFileURL(join(pinDir, "scripts/plugin-verification-request.mjs")).href)
59
+ }
60
+
61
+ /**
62
+ * Which repository an issue is about.
63
+ *
64
+ * The marketplace has two issue forms and they are not interchangeable. A
65
+ * `[Plugin]:` submission is read by `extractRepositoryUrl`; a `[Verify]:` update
66
+ * request has its own headings, and feeding it to the submission parser fails
67
+ * in a misleading way, because "Repository URL" is a heading both forms use and
68
+ * the submission parser then runs the section on until the next heading it
69
+ * happens to recognise. So each form is read by the parser the marketplace
70
+ * itself uses for it, and the issue says which one it is.
71
+ */
72
+ async function repositoryFor(pinDir, subject) {
73
+ const submission = await loadSubmission(pinDir)
74
+ const verification = await loadVerification(pinDir)
75
+ const title = String(subject.title || "")
76
+ const attempts = title.startsWith("[Verify]")
77
+ ? [
78
+ ["verify", () => verification.parsePluginVerificationIssue(subject.body).repoUrl],
79
+ ["verify-legacy", () => verification.parseLegacyListedSnapshotVerificationIssue(subject.body).repoUrl],
80
+ ["submission", () => submission.extractRepositoryUrl(subject.body)],
81
+ ]
82
+ : [
83
+ ["submission", () => submission.extractRepositoryUrl(subject.body)],
84
+ ["verify", () => verification.parsePluginVerificationIssue(subject.body).repoUrl],
85
+ ]
86
+ const errors = []
87
+ for (const [kind, read] of attempts) {
88
+ try {
89
+ const url = read()
90
+ if (url) return { url, kind, error: null }
91
+ } catch (error) {
92
+ errors.push(`${kind}: ${error.message}`)
93
+ }
94
+ }
95
+ return { url: null, kind: null, error: errors.join("; ") }
96
+ }
97
+
98
+ /** The short commit the validation comment reports, as a fallback when no baseline marker exists. */
99
+ export function validationCommentCommit(comments) {
100
+ const validation = (comments || [])
101
+ .filter((comment) => String(comment.body || "").includes("<!-- marketplace-validation -->"))
102
+ .at(-1)
103
+ if (!validation) return null
104
+ const short = String(validation.body).match(/passed at commit `([0-9a-f]{7,40})`/i)?.[1]
105
+ return short ? { short: short.toLowerCase(), createdAt: validation.created_at || null } : null
106
+ }
107
+
108
+ /**
109
+ * @param {{ repoRoot: string, issueUrl: string }} options
110
+ */
111
+ export async function validationWatch({ repoRoot, issueUrl, onPhase }) {
112
+ // Optional: told the name of the step about to run, so a terminal can say
113
+ // what is happening while the network answers. Never affects the result.
114
+ const phase = onPhase || (() => {})
115
+ const { dir: pinDir } = requirePin(repoRoot)
116
+ const target = parseIssueUrl(issueUrl)
117
+ if (`${target.owner}/${target.repository}`.toLowerCase() !== MARKETPLACE_SLUG) {
118
+ throw new WatchError(
119
+ "usage",
120
+ `the watch only reads ${MARKETPLACE_PIN.repository} submissions, got ${target.owner}/${target.repository}`,
121
+ )
122
+ }
123
+
124
+ const record = await loadRecord(pinDir)
125
+
126
+ phase(`reading issue #${target.number}`)
127
+ const subject = await issue(target.owner, target.repository, target.number)
128
+ phase(`reading the comments on issue #${target.number}`)
129
+ const comments = await issueComments(target.owner, target.repository, target.number)
130
+
131
+ const read = await repositoryFor(pinDir, subject)
132
+ const repositoryUrl = read.url
133
+ const repositoryError = read.error
134
+ const issueKind = read.kind
135
+
136
+ let validated = null
137
+ let baselineError = null
138
+ try {
139
+ const marker = record.findLatestSecurityBaseline(comments)
140
+ if (marker) {
141
+ validated = {
142
+ commit: marker.commitSha,
143
+ source: "security-baseline-marker",
144
+ outcome: marker.outcome,
145
+ findings: marker.findings,
146
+ capabilities: marker.capabilities,
147
+ checkedAt: marker.checkedAt,
148
+ pluginIds: [...marker.pluginIds],
149
+ }
150
+ }
151
+ } catch (error) {
152
+ baselineError = { code: error.code || "baseline-unreadable", message: error.message }
153
+ }
154
+ const fallback = validated ? null : validationCommentCommit(comments)
155
+
156
+ const labels = (subject.labels || []).map((label) => (typeof label === "string" ? label : label?.name)).filter(Boolean)
157
+ const authorComments = comments.filter((comment) => comment?.user?.login === subject.user?.login)
158
+ const maintainerComments = comments.filter(
159
+ (comment) => comment?.user?.login && comment.user.login !== subject.user?.login && comment.user.login !== "github-actions[bot]",
160
+ )
161
+
162
+ let head = null
163
+ let headError = null
164
+ if (repositoryUrl) {
165
+ phase("reading the plugin repository's default-branch HEAD")
166
+ try {
167
+ head = await defaultBranchHead(repositoryUrl)
168
+ } catch (error) {
169
+ headError = { code: error.code || "head-unreadable", message: error.message }
170
+ }
171
+ }
172
+
173
+ const comparable = Boolean(validated?.commit && head?.commit)
174
+ const stale = comparable ? validated.commit !== head.commit : null
175
+ const lastReviewAt = Date.parse(maintainerComments.at(-1)?.created_at || "")
176
+ const headAt = Date.parse(head?.committedAt || "")
177
+ const pushedAfterReview = Boolean(
178
+ stale && Number.isFinite(lastReviewAt) && Number.isFinite(headAt) && headAt > lastReviewAt,
179
+ )
180
+
181
+ return {
182
+ read: {
183
+ issue: `${MARKETPLACE_PIN.repository}/issues/${target.number}`,
184
+ state: subject.state,
185
+ title: subject.title,
186
+ // Kept in the JSON for callers that need it, not printed: the text
187
+ // rendering ends up pasted into issues, reports and screenshots.
188
+ author: subject.user?.login || null,
189
+ labels,
190
+ createdAt: subject.created_at,
191
+ updatedAt: subject.updated_at,
192
+ bodyEditedAt: subject.body_edited_at || null,
193
+ comments: comments.length,
194
+ authorComments: authorComments.length,
195
+ maintainerComments: maintainerComments.length,
196
+ lastMaintainerCommentAt: maintainerComments.at(-1)?.created_at || null,
197
+ authenticated: Boolean(token()),
198
+ },
199
+ plugin: { repository: repositoryUrl, repositoryError, form: issueKind },
200
+ validated,
201
+ validationCommentFallback: fallback,
202
+ baselineError,
203
+ head,
204
+ headError,
205
+ verdict: validationVerdict({ comparable, stale, validated, head, fallback, baselineError, headError, pushedAfterReview }),
206
+ }
207
+ }
208
+
209
+ export function validationVerdict({ comparable, stale, validated, head, fallback, baselineError, headError, pushedAfterReview, repositoryUrl = "unknown" }) {
210
+ if (baselineError) {
211
+ return {
212
+ state: "unknown",
213
+ summary: `The latest automated baseline on this issue did not complete (${baselineError.code}), so there is no validated commit to compare.`,
214
+ action: REFRESH_ACTION,
215
+ }
216
+ }
217
+ if (!validated) {
218
+ return {
219
+ state: "unknown",
220
+ summary: fallback
221
+ ? `No security-baseline marker on this issue. Validation reported commit ${fallback.short}, which is too short to compare reliably.`
222
+ : "No automated validation or security baseline has run on this issue yet, so there is no validated commit.",
223
+ action: fallback ? REFRESH_ACTION : "Wait for the automated validation to run, or edit the issue body to trigger it.",
224
+ }
225
+ }
226
+ if (!repositoryUrl) {
227
+ return {
228
+ state: "unknown",
229
+ summary: `The validated commit is ${validated.commit}, but no plugin repository could be read from this issue, so there is nothing to compare it with.`,
230
+ action: null,
231
+ }
232
+ }
233
+ if (headError || !head) {
234
+ return {
235
+ state: "unknown",
236
+ summary: `The validated commit is ${validated.commit}, but the repository's current default-branch HEAD could not be read (${headError?.code || "unknown"}).`,
237
+ action: null,
238
+ }
239
+ }
240
+ if (!comparable) {
241
+ return { state: "unknown", summary: "Not enough information to compare the validated commit.", action: null }
242
+ }
243
+ if (!stale) {
244
+ return {
245
+ state: "current",
246
+ summary: `The validated commit is ${validated.commit}, which is the current ${head.branch || "default"}-branch HEAD. Nothing needs refreshing.`,
247
+ action: null,
248
+ }
249
+ }
250
+ return {
251
+ state: "stale",
252
+ summary: [
253
+ `The validated commit is ${validated.commit}.`,
254
+ `The repository's current ${head.branch || "default"}-branch HEAD is ${head.commit}.`,
255
+ "The marketplace has not seen the newer commit. Pushing it did not tell the marketplace, and neither did any comment.",
256
+ pushedAfterReview ? "The newer commit landed after the last human review comment on this issue." : null,
257
+ ].filter(Boolean).join(" "),
258
+ action: REFRESH_ACTION,
259
+ }
260
+ }
261
+
262
+ export { GitHubError }
@@ -0,0 +1,164 @@
1
+ // A deliberately small YAML reader for GitHub issue-form documents.
2
+ //
3
+ // Omakit has zero runtime dependencies, and the one YAML document it must read
4
+ // is the pinned marketplace issue form. That form uses a narrow subset: block
5
+ // maps, block sequences, plain and quoted scalars, and one literal block
6
+ // scalar. This parser accepts exactly that subset and throws on anything it
7
+ // does not understand, so an unreadable form is a loud failure instead of a
8
+ // silently wrong submission body.
9
+ //
10
+ // Not supported on purpose: flow collections, anchors, aliases, tags,
11
+ // multi-document streams, folded scalars with chomping indicators other than
12
+ // the two below. If the marketplace ever needs them, this file fails and the
13
+ // pin update is the moment to notice.
14
+
15
+ export class YamlError extends Error {
16
+ constructor(message, line) {
17
+ super(line === undefined ? message : `${message} (line ${line + 1})`)
18
+ this.name = "YamlError"
19
+ this.code = "form-unreadable"
20
+ this.line = line
21
+ }
22
+ }
23
+
24
+ const KEY = /^([A-Za-z0-9_][A-Za-z0-9_.\- ]*?)\s*:(?:\s+(.*))?$/
25
+ const ITEM = /^-(\s+|$)/
26
+
27
+ function isBlank(line) {
28
+ return line.trim() === ""
29
+ }
30
+
31
+ function isComment(line) {
32
+ return /^\s*#/.test(line)
33
+ }
34
+
35
+ function indentOf(line) {
36
+ return line.length - line.replace(/^ +/, "").length
37
+ }
38
+
39
+ function skip(lines, i) {
40
+ while (i < lines.length && (isBlank(lines[i]) || isComment(lines[i]))) i += 1
41
+ return i
42
+ }
43
+
44
+ export function parseScalar(token) {
45
+ const text = String(token).trim()
46
+ if (text === "" || text === "~" || text === "null") return text === "" ? "" : null
47
+ if (text === "true") return true
48
+ if (text === "false") return false
49
+ if (/^-?\d+$/.test(text)) return Number(text)
50
+ if (text.length >= 2 && text.startsWith('"') && text.endsWith('"')) {
51
+ return text
52
+ .slice(1, -1)
53
+ .replace(/\\n/g, "\n")
54
+ .replace(/\\t/g, "\t")
55
+ .replace(/\\"/g, '"')
56
+ .replace(/\\\\/g, "\\")
57
+ }
58
+ if (text.length >= 2 && text.startsWith("'") && text.endsWith("'")) {
59
+ return text.slice(1, -1).replace(/''/g, "'")
60
+ }
61
+ return text
62
+ }
63
+
64
+ function readBlockScalar(lines, i, parentIndent, chomp) {
65
+ const out = []
66
+ let base = null
67
+ while (i < lines.length) {
68
+ if (isBlank(lines[i])) {
69
+ out.push("")
70
+ i += 1
71
+ continue
72
+ }
73
+ const indent = indentOf(lines[i])
74
+ if (indent <= parentIndent) break
75
+ if (base === null) base = indent
76
+ out.push(lines[i].slice(Math.min(base, indent)))
77
+ i += 1
78
+ }
79
+ while (out.length && out.at(-1) === "") out.pop()
80
+ const text = out.join("\n")
81
+ return [chomp === "strip" ? text : `${text}\n`, i]
82
+ }
83
+
84
+ function parseNode(lines, i, minIndent) {
85
+ i = skip(lines, i)
86
+ if (i >= lines.length) return [null, i]
87
+ const indent = indentOf(lines[i])
88
+ if (indent < minIndent) return [null, i]
89
+ const body = lines[i].slice(indent)
90
+ if (ITEM.test(body)) return parseSequence(lines, i, indent)
91
+ if (KEY.test(body)) return parseMap(lines, i, indent)
92
+ throw new YamlError(`unsupported YAML construct: ${body.slice(0, 40)}`, i)
93
+ }
94
+
95
+ function parseSequence(lines, start, indent) {
96
+ const out = []
97
+ let i = start
98
+ for (;;) {
99
+ i = skip(lines, i)
100
+ if (i >= lines.length || indentOf(lines[i]) !== indent) break
101
+ const body = lines[i].slice(indent)
102
+ if (!ITEM.test(body)) break
103
+ const rest = body.replace(ITEM, "")
104
+ if (rest === "") {
105
+ const [value, next] = parseNode(lines, i + 1, indent + 1)
106
+ out.push(value)
107
+ i = next
108
+ continue
109
+ }
110
+ if (KEY.test(rest)) {
111
+ // An inline first key: re-present the line at the item's own indentation
112
+ // so the map parser sees a normal block map. Index arithmetic holds
113
+ // because entry 0 of the view stands for line i.
114
+ const itemIndent = indent + (body.length - rest.length)
115
+ const view = [" ".repeat(itemIndent) + rest, ...lines.slice(i + 1)]
116
+ const [value, consumed] = parseMap(view, 0, itemIndent)
117
+ out.push(value)
118
+ i += consumed
119
+ continue
120
+ }
121
+ out.push(parseScalar(rest))
122
+ i += 1
123
+ }
124
+ return [out, i]
125
+ }
126
+
127
+ function parseMap(lines, start, indent) {
128
+ const out = {}
129
+ let i = start
130
+ for (;;) {
131
+ i = skip(lines, i)
132
+ if (i >= lines.length || indentOf(lines[i]) !== indent) break
133
+ const body = lines[i].slice(indent)
134
+ const match = body.match(KEY)
135
+ if (!match) break
136
+ const key = match[1].trim()
137
+ if (Object.hasOwn(out, key)) throw new YamlError(`duplicate key "${key}"`, i)
138
+ const inline = match[2] === undefined ? "" : match[2].trim()
139
+ if (inline === "|" || inline === "|-") {
140
+ const [value, next] = readBlockScalar(lines, i + 1, indent, inline === "|-" ? "strip" : "clip")
141
+ out[key] = value
142
+ i = next
143
+ continue
144
+ }
145
+ if (inline === "") {
146
+ const [value, next] = parseNode(lines, i + 1, indent + 1)
147
+ out[key] = value
148
+ i = next
149
+ continue
150
+ }
151
+ out[key] = parseScalar(inline)
152
+ i += 1
153
+ }
154
+ return [out, i]
155
+ }
156
+
157
+ /** Parse one YAML document in the issue-form subset described above. */
158
+ export function parseYaml(text) {
159
+ const lines = String(text).replace(/\r\n?/g, "\n").split("\n")
160
+ const [value, next] = parseNode(lines, 0, 0)
161
+ const tail = skip(lines, next)
162
+ if (tail < lines.length) throw new YamlError(`unparsed trailing content: ${lines[tail].slice(0, 40)}`, tail)
163
+ return value
164
+ }