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,156 @@
1
+ // `omakit doctor`: what is installed, what is pinned, and what has moved.
2
+ //
3
+ // It reads and it prints. It installs nothing, updates nothing and touches no
4
+ // checkout, because the two things a person might want "upgraded" here are not
5
+ // the same thing and only one of them may ever move on its own.
6
+ //
7
+ // The tool version belongs to the installer: Git for a checkout, npm for its
8
+ // package, and pacman for the Arch package. A CLI that fetches and executes its
9
+ // own replacement is the supply-chain shape this repository warns about.
10
+ //
11
+ // The pin must not move by itself. Bumping it changes where the submission
12
+ // contract is read from, and the procedure in docs/UPSTREAM_CONTRACT.md requires
13
+ // re-proving transport parity and committing the evidence afterwards. An
14
+ // `upgrade` that quietly advanced the pin would break the one guarantee this
15
+ // tool sells. So doctor reports that the pin is behind and prints the procedure.
16
+ //
17
+ // That the pin goes stale unnoticed is, of course, exactly the defect class
18
+ // `omakit watch` exists to report. It would be poor form not to apply it here.
19
+
20
+ import { execFileSync } from "node:child_process"
21
+ import { readFileSync } from "node:fs"
22
+ import { join } from "node:path"
23
+ import { MARKETPLACE_PIN, marketplacePinDir, pinDiskUsage, pinIsSparse, requirePin } from "./pin.mjs"
24
+ import { credential, defaultBranchHead, getJson, UNAUTHENTICATED_LIMIT, GitHubError } from "./github.mjs"
25
+ import { upgradeCommand } from "./upgrade.mjs"
26
+
27
+ function tool(repoRoot) {
28
+ try {
29
+ const pkg = JSON.parse(readFileSync(join(repoRoot, "package.json"), "utf8"))
30
+ return { name: pkg.name, version: pkg.version, engines: pkg.engines?.node || null }
31
+ } catch {
32
+ return { name: "omakit", version: "unknown", engines: null }
33
+ }
34
+ }
35
+
36
+ function version(command, args = ["--version"]) {
37
+ try {
38
+ return execFileSync(command, args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim().split("\n")[0]
39
+ } catch {
40
+ return null
41
+ }
42
+ }
43
+
44
+ async function latestOnRegistry(name) {
45
+ try {
46
+ const meta = await getJson(`https://registry.npmjs.org/${encodeURIComponent(name)}/latest`)
47
+ return meta?.version || null
48
+ } catch {
49
+ return null
50
+ }
51
+ }
52
+
53
+ /** Full pin evidence for machines; human output deliberately keeps short hashes. */
54
+ export function pinFreshness(identity, head) {
55
+ const current = head.commit === identity.commit
56
+ return {
57
+ id: "pin.freshness",
58
+ state: current ? "ok" : "advice",
59
+ detail: current
60
+ ? `the pin is the marketplace's current ${head.branch || "default"}-branch HEAD`
61
+ : `the pin is ${identity.commit.slice(0, 7)}; the marketplace's ${head.branch || "default"} branch is now at ${head.commit.slice(0, 7)}`,
62
+ action: current ? null : "Bumping the pin is a deliberate change: docs/UPSTREAM_CONTRACT.md has the procedure, which ends in re-proving parity and committing its evidence. Nothing here does it for you.",
63
+ evidence: {
64
+ pinCommit: identity.commit,
65
+ marketplaceHead: head.commit,
66
+ branch: head.branch || "default",
67
+ },
68
+ }
69
+ }
70
+
71
+ /**
72
+ * @param {{ repoRoot: string, offline?: boolean }} options
73
+ */
74
+ export async function doctor({ repoRoot, offline = false, onPhase }) {
75
+ // Optional: told what is being read while the network answers. Never
76
+ // affects the result.
77
+ const phase = onPhase || (() => {})
78
+ const checks = []
79
+ const add = (id, state, detail, action = null, evidence = null) => checks.push({
80
+ id,
81
+ state,
82
+ detail,
83
+ action,
84
+ ...(evidence ? { evidence } : {}),
85
+ })
86
+
87
+ const self = tool(repoRoot)
88
+ add("omakit.version", "info", `${self.name} ${self.version}`)
89
+
90
+ const node = process.versions.node
91
+ const major = Number(node.split(".")[0])
92
+ add("node", major >= 22 ? "ok" : "problem", `node ${node}${self.engines ? ` (needs ${self.engines})` : ""}`,
93
+ major >= 22 ? null : "Install Node 22 or newer.")
94
+
95
+ const git = version("git")
96
+ add("git", git ? "ok" : "problem", git || "git was not found on PATH", git ? null : "Install git.")
97
+
98
+ const dir = marketplacePinDir(repoRoot)
99
+ let identity = null
100
+ try {
101
+ identity = requirePin(repoRoot).identity
102
+ add("pin.checkout", "ok",
103
+ `${identity.commit} (baseline ${identity.baselineVersion}, ${identity.enforcementMode}) at ${dir}`)
104
+ const sparse = pinIsSparse(dir)
105
+ add("pin.size", sparse ? "ok" : "advice", `${pinDiskUsage(dir)}${sparse ? ", sparse" : ", full checkout"}`,
106
+ sparse ? null : `This checkout predates the sparse fetch and is far larger than it needs to be. Remove ${dir} and run \`omakit pin\` to refetch only what omakit reads.`)
107
+ } catch (error) {
108
+ add("pin.checkout", "problem", error.message, error.remedy || "omakit pin")
109
+ }
110
+
111
+ if (!offline && identity) {
112
+ phase("reading the marketplace's current default-branch HEAD")
113
+ try {
114
+ const head = await defaultBranchHead(MARKETPLACE_PIN.repository)
115
+ checks.push(pinFreshness(identity, head))
116
+ } catch (error) {
117
+ add("pin.freshness", "unknown", `could not read the marketplace's HEAD (${error.code || "error"})`,
118
+ error.code === "network-unavailable" ? "Connect to the network, or pass --offline to skip the two checks that need it." : null,
119
+ { pinCommit: identity.commit, marketplaceHead: null, branch: null })
120
+ }
121
+
122
+ phase("asking the npm registry for the newest published version")
123
+ const latest = await latestOnRegistry(self.name)
124
+ if (latest) {
125
+ const current = latest === self.version
126
+ add("omakit.latest", current ? "ok" : "advice",
127
+ current ? `${latest} is the newest published version` : `${latest} is published, this is ${self.version}`,
128
+ current ? null : upgradeCommand(repoRoot, self.name))
129
+ } else {
130
+ add("omakit.latest", "unknown", "the npm registry did not answer, or this version is unpublished")
131
+ }
132
+ }
133
+
134
+ // Where the credential comes from, said out loud. Borrowing someone's `gh`
135
+ // login is the right default and a bad secret: a tool that quietly picks up a
136
+ // credential is a tool you cannot audit by reading its help text, so doctor
137
+ // names the source every time.
138
+ // Just "gh version 2.62.0": the build date gh prints after it would nest a
139
+ // second parenthetical inside this line.
140
+ phase("reading the GitHub credential from gh")
141
+ const cli = version("gh")?.replace(/\s*\(.*\)\s*$/, "") || null
142
+ const auth = credential({ refresh: true })
143
+ add("github.auth", auth.value ? "ok" : "info",
144
+ auth.source === "gh"
145
+ ? `read-only, from your \`gh\` login${cli ? ` (${cli})` : ""}; omakit stores nothing`
146
+ : `${auth.detail}. \`submit\` and \`verify\` need none at all; \`watch\` and \`parity\` are capped without one`,
147
+ auth.value
148
+ ? null
149
+ : cli
150
+ ? "`gh auth login` is enough. omakit reads that login for GET requests only and never copies it anywhere."
151
+ : "Install GitHub's `gh` CLI and run `gh auth login`. omakit reads that login for GET requests only.")
152
+
153
+ return { checks, problems: checks.filter((check) => check.state === "problem").length }
154
+ }
155
+
156
+ export { GitHubError }
@@ -0,0 +1,115 @@
1
+ // The one text effect: the wordmark through `ttfx`, where the wordmark is
2
+ // drawn (a bare `omakit` and `omakit setup`), and only into a terminal.
3
+ //
4
+ // `ttfx` (github.com/omacom/ttfx) is the Rust port of TerminalTextEffects that
5
+ // Omarchy's own screensaver draws with, so its vocabulary reads as native
6
+ // here. It is an enhancement and never a requirement: absent, unexecutable or
7
+ // over budget, the wordmark looks exactly as it does without it, and the probe is a
8
+ // spawn that fails silently, the way the `gh` credential lookup does.
9
+ //
10
+ // Colour stays omakit's. Measured before this was decided, the wordmark with
11
+ // omakit's palette escapes piped through `ttfx` in each of its
12
+ // `--existing-color-handling` modes: `ignore` paints its own truecolor
13
+ // gradient, `always` and `dynamic` honour the input tint but re-encode palette
14
+ // index 36 as the fixed truecolor 0;128;128 and drop 39, so the Omarchy theme
15
+ // no longer decides what cyan is. `--xterm-colors` is the same in 256-colour,
16
+ // which this repository bans just the same. So the effect runs with
17
+ // `--no-color`, over the glyphs alone, whose density split survives because it
18
+ // is carried by the characters, and omakit repaints the finished wordmark in
19
+ // its own tints when the effect is over.
20
+ //
21
+ // The arguments are frozen and asserted by tests/unit/read-only.test.mjs:
22
+ // stdin only, no input file, no path, one pinned effect, one seed, so the
23
+ // recorded setup.gif is reproducible.
24
+
25
+ import { spawn, spawnSync } from "node:child_process"
26
+ import { MOTION } from "./style.mjs"
27
+
28
+ export const TTFX = "ttfx"
29
+
30
+ /** The probe: does `ttfx` start and say its name. */
31
+ export const TTFX_PROBE = Object.freeze(["--version"])
32
+
33
+ /**
34
+ * The effect. `--no-restore-cursor` leaves the cursor where an ordinary
35
+ * program would, on the line under the wordmark, which is where the repaint
36
+ * starts from; measured, the default leaves it on the wordmark's last row.
37
+ */
38
+ export const TTFX_ARGS = Object.freeze([
39
+ "--no-color",
40
+ "--no-restore-cursor",
41
+ "--seed", "1",
42
+ "--frame-rate", String(MOTION.effectFrameRate),
43
+ "expand",
44
+ ])
45
+
46
+ /** Milliseconds a probe may take: `ttfx` starts in half a millisecond, so this is generous. */
47
+ const PROBE_MS = 200
48
+
49
+ /**
50
+ * Whether `ttfx` is on PATH and runs. A spawn that fails for any reason is
51
+ * "no", silently.
52
+ *
53
+ * @param {NodeJS.ProcessEnv} [env]
54
+ */
55
+ export function effectAvailable(env = process.env) {
56
+ try {
57
+ const probe = spawnSync(TTFX, [...TTFX_PROBE], { encoding: "utf8", env, timeout: PROBE_MS, stdio: ["ignore", "pipe", "ignore"] })
58
+ return probe.status === 0 && /^ttfx \d/.test(probe.stdout)
59
+ } catch {
60
+ return false
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Run the effect over `rows`, relaying its frames to `stream` as they come.
66
+ * Resolves to what happened, so the caller knows what is on the screen:
67
+ *
68
+ * - "played": exit 0 inside the budget; the cursor is on the line under the
69
+ * rows, hidden, and the rows are drawn plain
70
+ * - "absent": nothing reached the screen; draw as if `ttfx` were not there
71
+ * - "broken": something reached the screen and then it failed or ran out of
72
+ * budget; the cursor is somewhere inside the rows
73
+ *
74
+ * @param {string[]} rows
75
+ * @param {{ write: (chunk: string|Buffer) => unknown }} stream
76
+ * @param {{ budgetMs?: number, env?: NodeJS.ProcessEnv }} [options]
77
+ * @returns {Promise<"played"|"absent"|"broken">}
78
+ */
79
+ export function playEffect(rows, stream, { budgetMs = MOTION.effectBudgetMs, env = process.env } = {}) {
80
+ return new Promise((resolve) => {
81
+ let child
82
+ try {
83
+ child = spawn(TTFX, [...TTFX_ARGS], { env, stdio: ["pipe", "pipe", "ignore"] })
84
+ } catch {
85
+ resolve("absent")
86
+ return
87
+ }
88
+ let written = 0
89
+ let settled = false
90
+ let overBudget = false
91
+ const settle = (outcome) => {
92
+ if (settled) return
93
+ settled = true
94
+ clearTimeout(timer)
95
+ resolve(outcome)
96
+ }
97
+ // Over budget the process is killed and the promise settles when it has
98
+ // exited, not when its pipe closes: a child it left behind could hold the
99
+ // pipe open. In budget it settles on `close`, after the last relayed
100
+ // chunk, so the caller never repaints under a late frame.
101
+ const timer = setTimeout(() => {
102
+ overBudget = true
103
+ child.once("exit", () => settle(written ? "broken" : "absent"))
104
+ child.kill("SIGKILL")
105
+ }, budgetMs)
106
+ child.on("error", () => settle(written ? "broken" : "absent"))
107
+ child.stdin.on("error", () => {})
108
+ child.stdout.on("data", (chunk) => {
109
+ written += chunk.length
110
+ stream.write(chunk)
111
+ })
112
+ child.on("close", (code) => settle(code === 0 && !overBudget && written ? "played" : written ? "broken" : "absent"))
113
+ child.stdin.end(`${rows.join("\n")}\n`)
114
+ })
115
+ }
@@ -0,0 +1,209 @@
1
+ // The submission contract, read from the pinned marketplace checkout.
2
+ //
3
+ // Nothing about the submission format is written down in Omakit. The title
4
+ // prefix, the field order, the controlled category and tag lists and the exact
5
+ // checklist text all come from the form an author actually fills in,
6
+ // `.github/ISSUE_TEMPLATE/submit-plugin.yml`, at the pinned commit. Updating
7
+ // the pin changes the contract; no Omakit source changes.
8
+ //
9
+ // Measured reason this is derived rather than copied (docs/MEASUREMENTS.md M2):
10
+ // 39 submissions fell out on the title prefix alone, 14 of them still open and
11
+ // 7 rescued by hand; 11 more open submissions are malformed in the body and get
12
+ // "The validation result could not be published to the issue. A maintainer must
13
+ // review the workflow.", which blames the maintainer for the author's mistake.
14
+ // One of those 11 differs from a valid submission by the single word
15
+ // "Suggested" instead of "Suggest". A contract that drifts from the form by one
16
+ // word reproduces exactly that failure, so the form is the only source.
17
+ //
18
+ // The derived contract is then cross-checked against the marketplace's own
19
+ // constants in `scripts/submission.mjs` at the same commit. Both halves must
20
+ // agree; divergence is a loud failure, never a silent guess.
21
+
22
+ import { readFileSync } from "node:fs"
23
+ import { join } from "node:path"
24
+ import { pathToFileURL } from "node:url"
25
+ import { parseYaml } from "./yaml.mjs"
26
+ import { requirePin } from "./pin.mjs"
27
+
28
+ export const SUBMIT_FORM_PATH = ".github/ISSUE_TEMPLATE/submit-plugin.yml"
29
+ export const OFFICIAL_SUBMISSION_MODULE = "scripts/submission.mjs"
30
+
31
+ export class ContractError extends Error {
32
+ constructor(code, message) {
33
+ super(message)
34
+ this.name = "ContractError"
35
+ this.code = code
36
+ }
37
+ }
38
+
39
+ /**
40
+ * The marketplace lowercases a submitted tag and joins words with a hyphen
41
+ * (`normalizeTag` in scripts/submission.mjs). Omakit does not assume that
42
+ * mapping: every label it derives is checked against the official
43
+ * `allowedTags` below, so a form label that stops mapping is an error.
44
+ */
45
+ export function tagSlug(label) {
46
+ return String(label).trim().toLowerCase().replace(/\s+/g, "-")
47
+ }
48
+
49
+ function loadOfficial(pinDir) {
50
+ return import(pathToFileURL(join(pinDir, OFFICIAL_SUBMISSION_MODULE)).href)
51
+ }
52
+
53
+ function fieldsOf(form) {
54
+ if (!Array.isArray(form?.body)) throw new ContractError("form-unreadable", `${SUBMIT_FORM_PATH} has no body`)
55
+ return form.body
56
+ .filter((item) => item && item.type !== "markdown")
57
+ .map((item) => ({
58
+ id: item.id || "",
59
+ type: item.type || "",
60
+ label: item.attributes?.label || "",
61
+ description: item.attributes?.description || "",
62
+ options: Array.isArray(item.attributes?.options) ? item.attributes.options : null,
63
+ multiple: item.attributes?.multiple === true,
64
+ required: item.validations?.required === true,
65
+ }))
66
+ }
67
+
68
+ function only(fields, predicate, what) {
69
+ const found = fields.filter(predicate)
70
+ if (found.length !== 1) {
71
+ throw new ContractError(
72
+ "form-shape-changed",
73
+ `${SUBMIT_FORM_PATH} no longer has exactly one ${what} (found ${found.length}); the pin changed shape and the submission contract must be re-read before anything is generated`,
74
+ )
75
+ }
76
+ return found[0]
77
+ }
78
+
79
+ /**
80
+ * Read the submission contract from the pinned checkout.
81
+ * @param {{ repoRoot?: string, pinDir?: string }} [options]
82
+ */
83
+ export async function submissionContract(options = {}) {
84
+ const pinDir = options.pinDir || requirePin(options.repoRoot).dir
85
+ const raw = readFileSync(join(pinDir, SUBMIT_FORM_PATH), "utf8")
86
+ const form = parseYaml(raw)
87
+ const fields = fieldsOf(form)
88
+
89
+ const titleTemplate = typeof form.title === "string" ? form.title : ""
90
+ if (!titleTemplate.trim()) throw new ContractError("form-shape-changed", `${SUBMIT_FORM_PATH} has no title template`)
91
+
92
+ const repository = only(fields, (f) => f.type === "input" && f.required, "required repository input")
93
+ const category = only(fields, (f) => f.type === "dropdown" && !f.multiple, "single-select dropdown (category)")
94
+ const tags = only(fields, (f) => f.type === "dropdown" && f.multiple, "multi-select dropdown (tags)")
95
+ const checklist = only(fields, (f) => f.type === "checkboxes", "checkboxes group (submission checklist)")
96
+
97
+ const official = await loadOfficial(pinDir)
98
+
99
+ const headings = fields.map((field) => field.label)
100
+ if (headings.some((heading) => !heading)) throw new ContractError("form-shape-changed", `${SUBMIT_FORM_PATH} has a field without a label`)
101
+
102
+ const checklistItems = (checklist.options || []).map((option) =>
103
+ typeof option === "string" ? { label: option, required: false } : { label: option.label, required: option.required === true },
104
+ )
105
+
106
+ const contract = {
107
+ formPath: SUBMIT_FORM_PATH,
108
+ titleTemplate,
109
+ titlePrefix: titleTemplate.trimEnd(),
110
+ labels: Array.isArray(form.labels) ? [...form.labels] : [],
111
+ headings,
112
+ fields,
113
+ headingFor: {
114
+ repository: repository.label,
115
+ category: category.label,
116
+ tags: tags.label,
117
+ checklist: checklist.label,
118
+ },
119
+ categories: [...(category.options || [])],
120
+ tagLabels: [...(tags.options || [])],
121
+ checklist: checklistItems,
122
+ maximumTags: official.maximumSubmissionTags,
123
+ official: {
124
+ titlePrefix: official.submissionTitlePrefix,
125
+ allowedCategories: [...official.allowedCategories],
126
+ allowedTags: [...official.allowedTags],
127
+ maximumSubmissionTags: official.maximumSubmissionTags,
128
+ checklist: [...official.submissionChecklist],
129
+ },
130
+ parseCurrentSubmission: official.parseCurrentSubmission,
131
+ extractRepositoryUrl: official.extractRepositoryUrl,
132
+ }
133
+
134
+ assertContractAgrees(contract)
135
+ return contract
136
+ }
137
+
138
+ /**
139
+ * The form and the marketplace's own constants must describe the same
140
+ * submission. Anything else means the pin is internally inconsistent, and
141
+ * generating a body from half of it would produce the malformed submissions
142
+ * this tool exists to prevent.
143
+ */
144
+ export function assertContractAgrees(contract) {
145
+ const divergence = []
146
+ if (!contract.titlePrefix.startsWith(contract.official.titlePrefix)) {
147
+ divergence.push(`form title ${JSON.stringify(contract.titleTemplate)} does not start with the official prefix ${JSON.stringify(contract.official.titlePrefix)}`)
148
+ }
149
+ const categories = contract.categories.join("|")
150
+ const officialCategories = contract.official.allowedCategories.join("|")
151
+ if (categories !== officialCategories) {
152
+ divergence.push(`form categories [${categories}] differ from allowedCategories [${officialCategories}]`)
153
+ }
154
+ const slugs = contract.tagLabels.map(tagSlug)
155
+ const unmapped = slugs.filter((slug) => !contract.official.allowedTags.includes(slug))
156
+ if (unmapped.length) {
157
+ divergence.push(`form tags do not map onto allowedTags: ${unmapped.join(", ")}`)
158
+ }
159
+ const checklist = contract.checklist.map((item) => item.label).join("\n")
160
+ const officialChecklist = contract.official.checklist.join("\n")
161
+ if (checklist !== officialChecklist) {
162
+ divergence.push("form checklist text differs from submissionChecklist")
163
+ }
164
+ if (!Number.isInteger(contract.maximumTags) || contract.maximumTags < 1) {
165
+ divergence.push(`maximumSubmissionTags is not a positive integer: ${contract.maximumTags}`)
166
+ }
167
+ if (divergence.length) {
168
+ throw new ContractError(
169
+ "contract-divergent",
170
+ `the pinned marketplace commit is internally inconsistent, so no submission is generated:\n - ${divergence.join("\n - ")}`,
171
+ )
172
+ }
173
+ }
174
+
175
+ /** Resolve one author-supplied category against the form's controlled list. */
176
+ export function resolveCategory(contract, value) {
177
+ const text = String(value ?? "").trim()
178
+ if (!text) return { ok: false, reason: "no category given" }
179
+ const exact = contract.categories.find((option) => option === text)
180
+ if (exact) return { ok: true, value: exact }
181
+ const loose = contract.categories.find((option) => option.toLowerCase() === text.toLowerCase())
182
+ if (loose) return { ok: true, value: loose }
183
+ return { ok: false, reason: `"${text}" is not one of the form's categories` }
184
+ }
185
+
186
+ /**
187
+ * Resolve author-supplied tags against the form's controlled list, accepting
188
+ * either the form's display labels or the marketplace's normalised slugs, and
189
+ * always emitting the display labels the form itself would write.
190
+ */
191
+ export function resolveTags(contract, values) {
192
+ const given = (Array.isArray(values) ? values : String(values ?? "").split(","))
193
+ .map((value) => String(value).trim())
194
+ .filter(Boolean)
195
+ if (!given.length) return { ok: false, reason: "no tags given", unknown: [] }
196
+ const bySlug = new Map(contract.tagLabels.map((label) => [tagSlug(label), label]))
197
+ const resolved = []
198
+ const unknown = []
199
+ for (const value of given) {
200
+ const label = bySlug.get(tagSlug(value))
201
+ if (!label) unknown.push(value)
202
+ else if (!resolved.includes(label)) resolved.push(label)
203
+ }
204
+ if (unknown.length) return { ok: false, reason: `not on the form's tag list: ${unknown.join(", ")}`, unknown }
205
+ if (resolved.length < 1 || resolved.length > contract.maximumTags) {
206
+ return { ok: false, reason: `the form takes 1 to ${contract.maximumTags} tags, got ${resolved.length}`, unknown: [] }
207
+ }
208
+ return { ok: true, value: resolved, unknown: [] }
209
+ }