omakit 0.6.9 → 0.6.10

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omakit",
3
- "version": "0.6.9",
3
+ "version": "0.6.10",
4
4
  "description": "Tested plumbing, the marketplace's own checks, and a disposable Omarchy to test in.",
5
5
  "license": "MIT",
6
6
  "author": "Maarten Tolhuijs",
@@ -1,5 +1,5 @@
1
1
  {
2
- "commit": "275ab1557edc5c8d152068e323659a5cc831ebbe",
3
- "recordedAt": "2026-09-23T09:18:44.425Z",
2
+ "commit": "517b87d52ba42465a12ab5722e979ed6f8d27805",
3
+ "recordedAt": "2026-10-01T02:52:49.108Z",
4
4
  "how": "Written by `node tools/blocks/record-commit.mjs` in the release workflow before `npm pack`, so a packaged omakit, which has no Git checkout, still names the commit its block files come from. In a checkout this file is null and `git rev-parse HEAD` is the source; a package with null here was packed without the release step, and `omakit add` refuses to stamp a header it cannot name."
5
5
  }
@@ -134,7 +134,7 @@ export async function clearStartupNotifications(guest, { onPhase = () => {} } =
134
134
  * the package or from a linked checkout (`OMARCHY_PATH` in
135
135
  * /etc/omarchy.conf; a dev-linked guest names `.local/share/omarchy`).
136
136
  * This is the line the old gates never wrote (docs/history/2026-09-18-lab-
137
- * inventory.md P8), and packaging/LAB_PLAN.md's run identity requires.
137
+ * inventory.md P8), and docs/LAB.md's run identity requires.
138
138
  */
139
139
  export function guestIdentity(guest) {
140
140
  const read = (command) => sshGuest(guest, command).stdout.trim()
@@ -1,7 +1,7 @@
1
1
  // What the host has and what it lacks, each with its measured reason.
2
2
  //
3
3
  // Read-only: every probe is a stat, a read of /proc, or a `--version`.
4
- // Nothing here installs anything (packaging/LAB_PLAN.md: lab-only host
4
+ // Nothing here installs anything (docs/LAB.md: lab-only host
5
5
  // capabilities are preflighted and reported, never installed). Measured
6
6
  // before this (docs/history/2026-09-18-lab-inventory.md P10, P20): the
7
7
  // upstream harness ran `omarchy-pkg-add` for six packages at the top of
@@ -168,7 +168,7 @@ export function probeRunHost({ pin = labPin(), run, env = process.env } = {}) {
168
168
 
169
169
  /**
170
170
  * The vCPU count a run gives the guest: every logical CPU, which is what
171
- * the toolchain's `-smp $(nproc)` gave the reference build (32, packaging/LAB_PLAN.md M4, M14). A
171
+ * the toolchain's `-smp $(nproc)` gave the reference build (32, M14). A
172
172
  * smaller number would be a guess about what a suite needs, and no run
173
173
  * has measured one.
174
174
  */
@@ -29,7 +29,7 @@ export function labStateDir(env = process.env) {
29
29
 
30
30
  /**
31
31
  * The layout under the cache root. One base, not one per release
32
- * (packaging/LAB_PLAN.md, the one-base rule): a pin update replaces it.
32
+ * (docs/LAB.md): setup replaces it when a newer release is built.
33
33
  */
34
34
  export function labLayout(env = process.env) {
35
35
  const cache = labCacheDir(env)
@@ -33,6 +33,6 @@
33
33
  "preparedLabBytes": 12442931200,
34
34
  "overlayAfterRunBytes": 610734080,
35
35
  "pluginsBytes": 6836224,
36
- "source": "docs/MEASUREMENTS.md M14, measured by omakit lab on the reference host with Omarchy 4.0.3; packaging/LAB_PLAN.md M4 to M7 are the 2026-09-10 to 2026-09-13 observations it re-measured"
36
+ "source": "docs/MEASUREMENTS.md M14, measured by omakit lab on the reference host with Omarchy 4.0.3, re-measuring the lab plan's observations of 2026-09-10 to 2026-09-13 (M4 to M7, in git history at 9c667f9)"
37
37
  }
38
38
  }
@@ -5,8 +5,8 @@
5
5
  // The signature is the trust: a substituted ISO with a substituted
6
6
  // checksum beside it fails here, because only Omarchy's key signs. The
7
7
  // digest names the file and catches a download that went wrong. Both,
8
- // every time, and a mismatch in either fails closed (packaging/LAB_PLAN.md,
9
- // the acquisition boundary).
8
+ // every time, and a mismatch in either fails closed (docs/LAB.md, the
9
+ // trust anchor).
10
10
  // The key is imported into a throwaway GNUPGHOME under the lab, never into
11
11
  // the user's keyring: a lab that added keys to ~/.gnupg would be changing
12
12
  // the host, and the only host change the lab makes is the lab.
@@ -7,14 +7,14 @@ local commit through the transport seam the marketplace tests itself
7
7
 
8
8
  | File | Purpose |
9
9
  | --- | --- |
10
- | `pin.mjs` | The pin identity (one home) and the reproducible setup: `omakit pin` fetches exactly that commit into `$XDG_CACHE_HOME/omakit/marketplace` and refuses a modified checkout. `PIN_PATHS` is what is fetched; `PIN_READS` is what omakit opens under `scripts/` and how (imported or read as text), and `pinnedReadSet(pinDir)` adds what the imported files import, by regex over the pinned text and never by loading a module: 16 of 34 files at `b7b29654`, pinned by `tests/unit/pin.test.mjs`. `policyConstants(text)` reads the two policy constants out of the module's text, for the pin's identity and for doctor's comparison at HEAD. |
10
+ | `pin.mjs` | The pin identity (one home) and the reproducible setup: `omakit pin` fetches exactly that commit into `$XDG_CACHE_HOME/omakit/marketplace` and refuses a modified checkout. `PIN_PATHS` is what is fetched; `PIN_READS` is what omakit opens under `scripts/` and how (imported or read as text), and `pinnedReadSet(pinDir)` adds what the imported files import, by regex over the pinned text and never by loading a module: 16 of 34 files at `b441b4f0`, pinned by `tests/unit/pin.test.mjs`. `policyConstants(text)` reads the two policy constants out of the module's text, for the pin's identity and for doctor's comparison at HEAD; each file read as text carries its `readers` in `PIN_READS`, the values taken out of it as functions from text to value (`reservedNamespace`, `catalogPresentation`, `validatedLabel`, `updateLabel`), which the checks call on the pin's text and doctor on the pin's and HEAD's. |
11
11
  | `local-transport.mjs` | Answers the four request shapes the official resolver makes, from a local clone at the exact commit. No network, no credentials, no writes. With `subdir`, serves a directory below the root as the whole tree, which is how `inspect` keeps the baseline to the plugin's own tree. |
12
12
  | `run-baseline.mjs` | Runs the pinned official baseline over either transport and reports the pin identity beside the result. |
13
13
  | `verify.mjs` | Builds the `marketplaceBaseline` section: pin, transport, assumptions, the official result verbatim, the statement. `omakit verify` renders it for a person (`renderVerify` in `report.mjs`) and prints the document itself behind `--json` and `--out`. |
14
14
  | `preflight.mjs` | Translates that result into what it will cause on submission, using the pinned policy, and renders the marketplace's own report text with its attestation marker stripped and asserted absent. |
15
15
  | `yaml.mjs` | A deliberately small YAML reader for the pinned issue form. Accepts that subset and throws on anything else. |
16
16
  | `form.mjs` | The submission contract, read from the form and cross-checked against the marketplace's own constants. Also the route for a plugin that is already listed: the marketplace's verification form and its "newer commit" choice, read from `verify-plugin.yml` at the pin and cross-checked against `plugin-verification-request.mjs`. |
17
- | `registry.mjs` | The plugin-id and repository universe: the reserved namespace from the pinned catalog builder, the listed and retired ids and listed repositories from `registry.json` and `site/catalog.json` at the marketplace's current HEAD when the network is there (cached under `$XDG_CACHE_HOME/omakit/registry/<commit>/`, never in the pin) and at the pin with `--offline`; `liveFileUrl()` is the way to the raw file host for those two files, and `headTextUrl()` for the one text `doctor` compares and drops (the policy module at HEAD, `HEAD_TEXT_PATHS`), both at a 40-character commit. `sameRepository()` is the one rule for "the subject's own repository" (owner and name, case-insensitively, a trailing `.git` ignored), and `listingOf()` is what the catalog records about a listing: since when, which commit, verified or not, checked when. |
17
+ | `registry.mjs` | The plugin-id and repository universe: the reserved namespace from the pinned catalog builder, the listed and retired ids and listed repositories from `registry.json` and `site/catalog.json` at the marketplace's current HEAD when the network is there (cached under `$XDG_CACHE_HOME/omakit/registry/<commit>/`, never in the pin) and at the pin with `--offline`; `liveFileUrl()` is the way to the raw file host for those two files, and `headTextUrl()` for the text `doctor` compares and drops (the policy module and the three files read as text at HEAD, `HEAD_TEXT_PATHS`), both at a 40-character commit; `catalogBuilderText()` is the pin's catalog builder, and `reservedNamespace` and `catalogPresentation` are re-exported from `pin.mjs`. `sameRepository()` is the one rule for "the subject's own repository" (owner and name, case-insensitively, a trailing `.git` ignored), and `listingOf()` is what the catalog records about a listing: since when, which commit, verified or not, checked when. |
18
18
  | `tree.mjs` | The installable tree of a subject at one exact commit, from the Git object database. |
19
19
  | `plugin.mjs` | The root files the submission contract needs, and the declared plugin identity. |
20
20
  | `agent-control.mjs` | The recursive agent-control warning, and its remedy. |
@@ -23,7 +23,7 @@ local commit through the transport seam the marketplace tests itself
23
23
  | `issue.mjs` | Renders the issue the way the form would, then has the marketplace's own parser judge it. |
24
24
  | `submit.mjs` | Assembles every check with its measured reason, and withholds the body when a blocking check fails. Three outcomes: `ready` (the body), `refused` (a blocking check failed) and `listed` (the plugin is already listed by its own repository: `identity.available` passes with the listing's record, the five body checks are omitted rather than drawn as waiting, no body exists on purpose, and `listing` carries the listed commit against the local one and the form to use for a newer commit). Decides the category and tags after the registry: a listed plugin, own or taken, is asked for neither; an unlisted one without them is asked through `ask.mjs` at a terminal, and is a usage error otherwise. Ends with `reproduce`, the command line that repeats the run without asking. Under `--offline` the validation-commit check is `skipped`, not passed: verdict `skipped`, listed under `skipped` and not `unknown`, never blocking, and the READY line says "1 check skipped (--offline)". |
25
25
  | `ask.mjs` | The two questions `submit` asks a person at a terminal, and only there: category and tags, numbered from the pinned form, with the marketplace's own presentation for the manifest's kinds (read from the pinned catalog builder) as the default where it is on the list. Prompts on stderr, nothing persisted. |
26
- | `doctor.mjs` | What is installed, what is pinned, and what has moved. `omakit.version` is one check for one question, is this the current omakit: `ok` with "0.1.8, the newest published version", a `note` with "0.1.8; 0.1.9 is published" and the upgrade command, `unknown` with the registry's failure code, `info` offline; its evidence carries `{ installed, latest, source }`. `pin.freshness` compares what omakit reads between the pin and the marketplace's HEAD (`comparePin`): the 16 files in `pinnedReadSet()` by blob id, the two data files by blob id, the form directory by tree id, and the two policy constants by the module's text at HEAD when its blob moved; three tree reads, a fourth for `scripts/` only when its id moved, a fifth GET for the policy text only when that blob moved. Graded: `ok` when nothing in the read set moved (the data files are read live; `scripts/` moving elsewhere is said as "in none of the 16 files omakit reads"), `info` when a read file moved and both constants read the same (the files named, verdicts unchanged, nothing to do), `advice` when a constant differs, a read file is gone, or the form moved, with `omakit upgrade` as the action when a newer omakit is published and otherwise that the maintainer is notified; it never asks for an issue, the weekly workflow opens the one there is. Measured (M7): 5e401552, which changed only `repository-identity.mjs`, graded `advice` by tree id and grades `ok` here. The maintainer's pin procedure stays in `docs/UPSTREAM_CONTRACT.md` and is never printed. HEAD unreadable is `unknown`. `--json` carries `changedPaths`, `readLive` and `pinned` under the check's evidence as before, plus `reads`, `moved`, `missing` and `policy.{pin,head}`. |
26
+ | `doctor.mjs` | What is installed, what is pinned, and what has moved. `omakit.version` is one check for one question, is this the current omakit: `ok` with "0.1.8, the newest published version", a `note` with "0.1.8; 0.1.9 is published" and the upgrade command, `unknown` with the registry's failure code, `info` offline; its evidence carries `{ installed, latest, source }`. `pin.freshness` compares what omakit reads between the pin and the marketplace's HEAD (`comparePin`): the 16 files in `pinnedReadSet()` by blob id, the two data files by blob id, the form directory by tree id, the two policy constants by the module's text at HEAD when its blob moved, and each file read as text, when its blob moved, by the values its `readers` take out of it at both ends (`textReadsBetween`, `textReads`); three tree reads, a fourth for `scripts/` only when its id moved, one more GET per moved text. Graded on `effective`, the moved files less the text-read ones whose every value is unchanged: `ok` when it is empty (the data files are read live; `scripts/` moving elsewhere is said as "in none of the 16 files omakit reads"; an unchanged text-read file is named with how many values), `info` when it holds only `WORDING_READS`, `advice` when a verdict-bearing file moved, a text-read value differs (both printed), a read file is gone, or the form moved, with `omakit upgrade` as the action when a newer omakit is published and otherwise that the maintainer is notified; it never asks for an issue, the weekly workflow opens the one there is. Measured (M7): of the five commits since `38060f89` that touched the read set, tree and blob comparison grade five advice, this two. The maintainer's pin procedure stays in `docs/UPSTREAM_CONTRACT.md` and is never printed. HEAD unreadable is `unknown`. `--json` carries `changedPaths`, `readLive` and `pinned` under the check's evidence as before, plus `reads`, `moved`, `missing` and `policy.{pin,head}`. |
27
27
  | `watch.mjs` | The validation watch: validated commit versus current default-branch HEAD, and the one action that refreshes it. |
28
28
  | `github.mjs` | Read-only GitHub access. GET only. The credential is your `gh` login, read through one frozen `gh auth token` call, and is never written anywhere. |
29
29
  | `style.mjs` | The visual system, defined once: the palette, the status vocabulary, the block ramp, the columns, the motion budgets, and the composition helpers every command draws with. Six states: `▁ ok`, `█ FAIL`, `▓ note`, `░ info`, `▒ ?` for a check that could not be made, and `▔ skip` for a check a flag said not to make, the floor's ink at the ceiling so it is never read as a pass. `docs/TUI.md` explains it. |
@@ -747,7 +747,7 @@ async function cmdLab(args) {
747
747
  onLine: (line) => {
748
748
  spinner.done()
749
749
  // The identity block, once the guest has been read: the line
750
- // packaging/LAB_PLAN.md says every run prints before its suite.
750
+ // docs/LAB.md says every run prints before its suite.
751
751
  if (line.record) {
752
752
  narrate.write(`${withOutputStream(narrate, () => renderRunIdentity(line.record, { colour: colourEnabled(narrate) }))}\n\n`)
753
753
  return
@@ -54,11 +54,26 @@
54
54
  // a rule can change without its version. The other two commits (7dd6e56,
55
55
  // 40315f2) touched submission.mjs and the policy module, both executed,
56
56
  // and the forms, so they grade advice either way.
57
+ //
58
+ // Nor is "a file omakit reads moved" the same as "what omakit reads out of
59
+ // it moved", for the three files it reads as text and never runs. Measured
60
+ // on 2026-10-01 (M7, docs/evidence/pin-freshness/2026-10-01-grades.json):
61
+ // build-catalog.mjs, the most edited file under scripts/, moved three times
62
+ // after b7b29654 (a166e3ac, b612419c, cb7947ec), the reserved namespace and
63
+ // the category mapping read out of it the same each time, and the blob
64
+ // comparison graded each advice, a release for nothing. So a text-read file
65
+ // whose blob moved is read once at HEAD and each value its `readers`
66
+ // (PIN_READS) take out of it is compared with the pin's, by the same
67
+ // function; one whose every value is unchanged does not count as moved for
68
+ // the grade (`effective`), and one whose value differs is advice with both
69
+ // values printed. Of the five commits since 38060f89 that touched a file in
70
+ // the read set, the tree comparison and the blob comparison grade five
71
+ // advice and this grades two: 7dd6e56 and 40315f2, as above.
57
72
 
58
73
  import { execFileSync } from "node:child_process"
59
74
  import { existsSync, readFileSync } from "node:fs"
60
75
  import { join } from "node:path"
61
- import { MARKETPLACE_PIN, PIN_PATHS, POLICY_MODULE, WORDING_READS, marketplacePinDir, pinDiskUsage, pinShape, pinnedReadSet, policyConstants, requirePin } from "./pin.mjs"
76
+ import { MARKETPLACE_PIN, PIN_PATHS, PIN_READS, POLICY_MODULE, WORDING_READS, marketplacePinDir, pinDiskUsage, pinShape, pinnedReadSet, policyConstants, requirePin } from "./pin.mjs"
62
77
  import { LIVE_PATHS, headTextUrl } from "./registry.mjs"
63
78
  import { credential, defaultBranchHead, getJson, getText, GitHubError } from "./github.mjs"
64
79
  import { compareVersions, NPM_REGISTRY, registryLatest, upgradeCommand } from "./upgrade.mjs"
@@ -93,6 +108,45 @@ function pinObjectId(pinDir, commit, path) {
93
108
  return execFileSync("git", ["-C", pinDir, "rev-parse", `${commit}:${path}`], { timeout: 60_000, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim()
94
109
  }
95
110
 
111
+ /** A file's text at a commit in the pinned checkout. Local: the read set's blobs are in the checkout. */
112
+ function pinFileText(pinDir, commit, path) {
113
+ return execFileSync("git", ["-C", pinDir, "show", `${commit}:${path}`], { timeout: 60_000, encoding: "utf8", maxBuffer: 16 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"] })
114
+ }
115
+
116
+ /**
117
+ * Each value `readers` takes out of a file, read out of the pin's text and
118
+ * HEAD's by the same function, and whether the two are the same: equal
119
+ * when their JSON is, since every reader returns a string, null or plain
120
+ * data.
121
+ *
122
+ * @returns {Record<string, { pin: unknown, head: unknown, same: boolean }>}
123
+ */
124
+ export function textReadValues(readers, pinText, headText) {
125
+ return Object.fromEntries(Object.entries(readers).map(([name, read]) => {
126
+ const pin = read(pinText)
127
+ const head = read(headText)
128
+ return [name, { pin, head, same: JSON.stringify(pin) === JSON.stringify(head) }]
129
+ }))
130
+ }
131
+
132
+ /**
133
+ * The text-read files of `reads` (those PIN_READS gives `readers`), each
134
+ * with whether its blob moved and the values read out of it at both ends.
135
+ * The pin's text comes from the checkout; HEAD's is fetched once, and only
136
+ * for a file in `moved`: an identical blob has identical values, so an
137
+ * unmoved file's values are the pin's at both ends and cost no request.
138
+ * With nothing moved (a current pin) it reads locally only.
139
+ */
140
+ export async function textReadsBetween({ pinDir, pinCommit = MARKETPLACE_PIN.commit, headCommit = pinCommit, reads = pinnedReadSet(pinDir), moved = [], fetchText = getText }) {
141
+ const textReads = {}
142
+ for (const { path, readers } of PIN_READS.filter((read) => read.readers && reads.some((entry) => entry.path === read.path))) {
143
+ const atPin = pinFileText(pinDir, pinCommit, path)
144
+ const blobMoved = moved.includes(path)
145
+ textReads[path] = { moved: blobMoved, values: textReadValues(readers, atPin, blobMoved ? await fetchText(headTextUrl(headCommit, path)) : atPin) }
146
+ }
147
+ return textReads
148
+ }
149
+
96
150
  /** A PIN_PATHS pattern as a path: "/site/catalog.json" -> "site/catalog.json". */
97
151
  const asPath = (pattern) => pattern.replace(/^\/|\/$/g, "")
98
152
 
@@ -117,10 +171,20 @@ const FORMS = "/.github/ISSUE_TEMPLATE/"
117
171
  * `policyAtHead`; otherwise `policyAtHead` is null, because an identical
118
172
  * blob has identical constants. The text is never imported.
119
173
  *
174
+ * Each file PIN_READS reads as text is compared by what omakit reads out
175
+ * of it, under `textReads`: its `readers` run on the pin's text (from the
176
+ * checkout) and on HEAD's, and each value is returned with both sides and
177
+ * whether they are the same. HEAD's text is read once through `fetchText`
178
+ * only when the file's blob moved, one more GET per such file; an
179
+ * unmoved file's values are the pin's at both ends, and a file gone at
180
+ * HEAD is in `missing` and not here. The text is compared and dropped,
181
+ * never imported.
182
+ *
120
183
  * `fetchJson` and `fetchText` are injectable for tests.
121
184
  *
122
185
  * @returns {Promise<{ changedPaths: string[], reads: number, moved: string[], missing: string[],
123
- * policyAtHead: { baselineVersion: string, enforcementMode: string }|null }>}
186
+ * policyAtHead: { baselineVersion: string, enforcementMode: string }|null,
187
+ * textReads: Record<string, { moved: boolean, values: Record<string, { pin: unknown, head: unknown, same: boolean }> }> }>}
124
188
  */
125
189
  export async function comparePin({ pinDir, headCommit, pinCommit = MARKETPLACE_PIN.commit, fetchJson = getJson, fetchText = getText, reads = pinnedReadSet(pinDir) }) {
126
190
  const match = MARKETPLACE_PIN.repository.match(/^https:\/\/github\.com\/([^/]+)\/([^/]+)$/)
@@ -164,7 +228,8 @@ export async function comparePin({ pinDir, headCommit, pinCommit = MARKETPLACE_P
164
228
  }
165
229
  }
166
230
  const policyAtHead = moved.includes(POLICY_MODULE) ? policyConstants(await fetchText(headTextUrl(headCommit, POLICY_MODULE))) : null
167
- return { changedPaths, reads: reads.length, moved, missing, policyAtHead }
231
+ const textReads = await textReadsBetween({ pinDir, pinCommit, headCommit, reads: reads.filter((read) => !missing.includes(read.path)), moved, fetchText })
232
+ return { changedPaths, reads: reads.length, moved, missing, policyAtHead, textReads }
168
233
  }
169
234
 
170
235
  /** "a", "a and b", "a, b and c". */
@@ -186,24 +251,36 @@ const verdictBearing = (paths) => paths.filter((path) => !WORDING_READS.includes
186
251
  * from HEAD, and `pinned`, everything only a new pin can carry; its `moved`
187
252
  * and `missing` name the files omakit reads under scripts/ whose blob
188
253
  * differs at HEAD; its `policyAtHead` carries the two policy constants
189
- * there when the policy module moved.
254
+ * there when the policy module moved; its `textReads` carries, for each
255
+ * file read as text, the values omakit reads out of it at both ends.
256
+ *
257
+ * A file read as text whose blob moved and whose every value reads the
258
+ * same at HEAD does not count as moved for the grade: omakit takes those
259
+ * values out of it and nothing else, so the move cannot reach a verdict or
260
+ * a line. `effective` is `moved` without such files, and the grade is
261
+ * taken from it; `moved` keeps meaning the blob. Measured on 2026-10-01
262
+ * (M7): build-catalog.mjs moved three times after b7b29654, the reserved
263
+ * namespace and the category mapping read the same each time, and the
264
+ * blob comparison graded the pin advice for it.
190
265
  *
191
266
  * ok nothing omakit reads moved: HEAD moved elsewhere, or only in
192
267
  * the live-read data files, or scripts/ moved in none of the
193
- * files omakit reads.
194
- * info the only read files that moved are ones omakit takes wording
268
+ * files omakit reads, or only in files read as text whose values
269
+ * are unchanged (named, with how many values).
270
+ * info the only read files that count are ones omakit takes wording
195
271
  * or a label out of (WORDING_READS): what it prints may differ
196
272
  * at HEAD, what it passes or refuses cannot. A newer omakit will
197
273
  * carry the pin. Nothing for a user to do.
198
274
  * advice a file omakit executes or reads a rule from moved (a verdict
199
275
  * may differ at HEAD, whether or not the two policy constants
200
276
  * still read the same, since a rule can change without its
201
- * version), a read file is gone at HEAD, or the form directory
202
- * moved (the contract itself). The action is `upgrade` (the
203
- * command, when the caller found a newer omakit published), else
204
- * that the maintainer is notified; never an issue to open, since
205
- * the weekly pin-freshness workflow opens the one there is. The
206
- * two constants are printed beside the grade in both cases.
277
+ * version), a value read out of a text-read file differs (both
278
+ * values printed), a read file is gone at HEAD, or the form
279
+ * directory moved (the contract itself). The action is `upgrade`
280
+ * (the command, when the caller found a newer omakit published),
281
+ * else that the maintainer is notified; never an issue to open,
282
+ * since the weekly pin-freshness workflow opens the one there is.
283
+ * The two constants are printed beside the grade in both cases.
207
284
  *
208
285
  * Null for `comparison` means the comparison was not made, which stays
209
286
  * advice: HEAD moved and nothing here can say the pin is fine. Full commits
@@ -218,15 +295,22 @@ export function pinFreshness(identity, head, comparison = null, { upgrade = null
218
295
  const pinned = changed.filter((pattern) => !LIVE_PATHS.includes(asPath(pattern)))
219
296
  const moved = compared ? comparison.moved : []
220
297
  const missing = compared ? comparison.missing : []
298
+ const textReads = comparison?.textReads || {}
221
299
  const pinPolicy = { baselineVersion: identity.baselineVersion, enforcementMode: identity.enforcementMode }
222
300
  const headPolicy = (compared && comparison.policyAtHead) || pinPolicy
223
301
  const policyDiffers = headPolicy.baselineVersion !== pinPolicy.baselineVersion || headPolicy.enforcementMode !== pinPolicy.enforcementMode
224
302
  const formMoved = pinned.includes(FORMS)
303
+ // The text-read files that moved, by whether every value read out of them is the same at HEAD.
304
+ const valuesOf = (path) => Object.entries(textReads[path]?.moved ? textReads[path].values : {})
305
+ const unchanged = moved.filter((path) => valuesOf(path).length && valuesOf(path).every(([, value]) => value.same))
306
+ const differing = moved.filter((path) => valuesOf(path).some(([, value]) => !value.same))
307
+ const effective = moved.filter((path) => !unchanged.includes(path))
225
308
  const verdictMoved = verdictBearing(moved)
309
+ const verdictEffective = verdictBearing(effective)
226
310
  const graded = moved.length > 0 || missing.length > 0 || formMoved
227
311
  const state = current ? "ok"
228
- : comparison === null || policyDiffers || formMoved || missing.length || verdictMoved.length ? "advice"
229
- : moved.length ? "info"
312
+ : comparison === null || policyDiffers || formMoved || missing.length || differing.length || verdictEffective.length ? "advice"
313
+ : effective.length ? "info"
230
314
  : "ok"
231
315
 
232
316
  const where = `pin ${identity.commit.slice(0, 7)}; marketplace ${branch} at ${head.commit.slice(0, 7)}`
@@ -234,7 +318,14 @@ export function pinFreshness(identity, head, comparison = null, { upgrade = null
234
318
  if (comparison === null) clauses.push("the paths omakit reads were not compared")
235
319
  else if (!changed.length) clauses.push("nothing omakit reads moved")
236
320
  else {
237
- if (moved.length) clauses.push(`moved since the pin: ${list(moved)}${verdictMoved.length ? "" : " (wording and labels only)"}`)
321
+ if (effective.length) clauses.push(`moved since the pin: ${list(effective)}${verdictEffective.length || differing.length ? "" : " (wording and labels only)"}`)
322
+ for (const path of differing) {
323
+ for (const [name, value] of valuesOf(path).filter(([, entry]) => !entry.same)) clauses.push(`${name} ${JSON.stringify(value.pin)} at the pin, ${JSON.stringify(value.head)} at HEAD`)
324
+ }
325
+ if (unchanged.length) {
326
+ const count = unchanged.reduce((sum, path) => sum + valuesOf(path).length, 0)
327
+ clauses.push(`${list(unchanged)} moved, and ${count === 1 ? "the one value" : `the ${count} values`} omakit reads from ${unchanged.length === 1 ? "it" : "them"} ${count === 1 ? "is" : "are"} unchanged`)
328
+ }
238
329
  if (missing.length) clauses.push(`gone at HEAD: ${list(missing)}`)
239
330
  if (formMoved) clauses.push(`the form moved (${asPath(FORMS)}/)`)
240
331
  if (pinned.includes("/scripts/") && !moved.length && !missing.length) clauses.push(`scripts/ moved in none of the ${comparison.reads} files omakit reads`)
@@ -245,7 +336,7 @@ export function pinFreshness(identity, head, comparison = null, { upgrade = null
245
336
  const detail = current ? `the pin is the marketplace's current ${branch}-branch HEAD` : `${where}; ${clauses.join("; ")}${tail}`
246
337
 
247
338
  const action = state === "info"
248
- ? `${moved.length === 1 ? "That file is" : "Those files are"} read for wording and labels, not for a pass or a refusal; what omakit prints may differ at HEAD, what it decides cannot. A newer omakit will carry the pin; nothing to do.`
339
+ ? `${effective.length === 1 ? "That file is" : "Those files are"} read for wording and labels, not for a pass or a refusal; what omakit prints may differ at HEAD, what it decides cannot. A newer omakit will carry the pin; nothing to do.`
249
340
  : state === "advice"
250
341
  ? upgrade
251
342
  ? `A newer omakit is published and may carry the pin: run \`${upgrade}\`. Until then every verdict here is the pin's, and the marketplace's own run on your issue is the one that counts.`
@@ -268,7 +359,9 @@ export function pinFreshness(identity, head, comparison = null, { upgrade = null
268
359
  reads: comparison.reads,
269
360
  moved,
270
361
  verdictMoved,
362
+ effective,
271
363
  missing,
364
+ textReads,
272
365
  policy: { pin: pinPolicy, head: headPolicy },
273
366
  }),
274
367
  },
@@ -396,10 +489,14 @@ export async function doctor({ repoRoot, offline = false, onPhase, env = process
396
489
  phase("reading the marketplace's current default-branch HEAD")
397
490
  try {
398
491
  const head = await resolveHead(MARKETPLACE_PIN.repository)
399
- let comparison = { changedPaths: [], reads: pinnedReadSet(dir).length, moved: [], missing: [], policyAtHead: null }
492
+ let comparison
400
493
  if (head.commit !== identity.commit) {
401
494
  phase("comparing each file omakit reads between the pin and HEAD")
402
495
  comparison = await compare({ pinDir: dir, headCommit: head.commit, pinCommit: identity.commit })
496
+ } else {
497
+ // The pin is HEAD: nothing moved, and the values read as text are the pin's, read locally.
498
+ const reads = pinnedReadSet(dir)
499
+ comparison = { changedPaths: [], reads: reads.length, moved: [], missing: [], policyAtHead: null, textReads: await textReadsBetween({ pinDir: dir, pinCommit: identity.commit, reads }) }
403
500
  }
404
501
  checks.push(pinFreshness(identity, head, comparison, { upgrade }))
405
502
  } catch (error) {
@@ -6,7 +6,8 @@
6
6
  // of which 168 MB is preview imagery and 151 MB is history, and omakit reads
7
7
  // twenty files out of it, sixteen of them under scripts/ (PIN_READS). A
8
8
  // blob-filtered, sparsely checked out fetch of just those paths is 15 MB and
9
- // takes 2 seconds instead of 17. PIN_PATHS below is the whole list, and
9
+ // takes 2 seconds instead of 17; at pin b441b4f0 (2026-10-01) the same fetch
10
+ // was 23 MB on disk in 3.1 seconds. PIN_PATHS below is the whole list, and
10
11
  // tests/unit/pin.test.mjs fails if any module starts reading a path outside
11
12
  // it, because on a partial clone such a read would quietly reach for the
12
13
  // network instead of failing.
@@ -27,8 +28,8 @@ export class PinError extends Error {
27
28
 
28
29
  export const MARKETPLACE_PIN = Object.freeze({
29
30
  repository: "https://github.com/omacom/omarchy-plugin-marketplace",
30
- commit: "b7b2965431c52fc6311fdb389bd9f0d6275235c4",
31
- commitSubject: "Add OmaStudio plugin (#6843)",
31
+ commit: "b441b4f059132b5b7203a7563027f2b3a9adaa1d",
32
+ commitSubject: "Add LINE for Omarchy plugin (#9470)",
32
33
  baselineVersion: "3",
33
34
  enforcementMode: "selective",
34
35
  })
@@ -40,8 +41,9 @@ export const MARKETPLACE_PIN = Object.freeze({
40
41
  * /scripts/ the official submission parser, baseline
41
42
  * scanner, policy, report and record modules,
42
43
  * and build-catalog.mjs read as text for the
43
- * reserved plugin-id namespace. Taken whole
44
- * because those modules import each other.
44
+ * reserved plugin-id namespace and the
45
+ * category mapping. Taken whole because
46
+ * those modules import each other.
45
47
  * /registry.json retired plugin ids, listed repositories
46
48
  * /site/catalog.json listed plugin ids
47
49
  * /.github/ISSUE_TEMPLATE/ the submission form: the whole contract
@@ -83,8 +85,19 @@ export const POLICY_MODULE = "scripts/security-baseline-policy.mjs"
83
85
  * approve-submission.mjs watch.mjs, text: one label
84
86
  * approve-plugin-update.mjs review-cost.mjs, text: one label
85
87
  *
88
+ * A file read as text carries `readers`: each value omakit takes out of
89
+ * it, by name, as a function from the file's text to the value. This is
90
+ * the one list of what is read out of such a file, and the functions are
91
+ * the ones the modules above call on the pin's text and the ones `omakit
92
+ * doctor` calls on the pin's text and on HEAD's, so the two sides of the
93
+ * comparison cannot read differently. Measured on 2026-10-01 (M7): the
94
+ * three commits that moved build-catalog.mjs since b7b29654 changed
95
+ * neither of the two values read out of it, and the blob comparison
96
+ * graded each of them advice.
97
+ *
86
98
  * tests/unit/pin.test.mjs derives this list from the sources and fails on
87
- * a read that is not here, or one listed the wrong way round.
99
+ * a read that is not here, or one listed the wrong way round, and holds
100
+ * `readers` to exactly the files read as text.
88
101
  */
89
102
  export const PIN_READS = Object.freeze([
90
103
  Object.freeze({ path: "scripts/submission.mjs", imported: true }),
@@ -94,9 +107,9 @@ export const PIN_READS = Object.freeze([
94
107
  Object.freeze({ path: "scripts/security-baseline-report.mjs", imported: true }),
95
108
  Object.freeze({ path: "scripts/security-baseline-record.mjs", imported: true }),
96
109
  Object.freeze({ path: "scripts/submission-feedback.mjs", imported: true }),
97
- Object.freeze({ path: "scripts/build-catalog.mjs", imported: false }),
98
- Object.freeze({ path: "scripts/approve-submission.mjs", imported: false }),
99
- Object.freeze({ path: "scripts/approve-plugin-update.mjs", imported: false }),
110
+ Object.freeze({ path: "scripts/build-catalog.mjs", imported: false, readers: Object.freeze({ reservedNamespace, catalogPresentation }) }),
111
+ Object.freeze({ path: "scripts/approve-submission.mjs", imported: false, readers: Object.freeze({ validatedLabel }) }),
112
+ Object.freeze({ path: "scripts/approve-plugin-update.mjs", imported: false, readers: Object.freeze({ updateLabel }) }),
100
113
  ])
101
114
 
102
115
  /**
@@ -126,9 +139,9 @@ export const RELATIVE_IMPORT = /\bfrom\s+["'](\.\.?\/[^"']+)["']/g
126
139
  * no guard; Node's ESM loader requires an extension.) If one of these ever
127
140
  * appears in an executed file of the pinned read set, pinnedReadSet() could
128
141
  * miss a file, and tests/unit/pin.test.mjs fails on the file and line
129
- * instead of doctor staying quiet. Measured at pin b7b29654: none of
130
- * the 13 executed files uses any of them (the three read as text are not
131
- * scanned, since nothing they import is loaded).
142
+ * instead of doctor staying quiet. Measured at pins b7b29654 and
143
+ * b441b4f0: none of the 13 executed files uses any of them (the three read
144
+ * as text are not scanned, since nothing they import is loaded).
132
145
  */
133
146
  export const UNFOLLOWED_IMPORTS = Object.freeze([
134
147
  Object.freeze({ form: "dynamic import()", pattern: /\bimport\s*\(/ }),
@@ -164,8 +177,8 @@ export function unfollowedImports(pinDir, reads = pinnedReadSet(pinDir)) {
164
177
  * the file that imports it or null for one omakit opens itself. A
165
178
  * specifier that leaves scripts/ (the marketplace's site/ assets) or names
166
179
  * a package (`sharp`) is not followed: PIN_PATHS does not fetch it, so
167
- * omakit could not read it. Measured at pin b7b29654: 16 of the 34 files
168
- * under scripts/, 10 opened by omakit and 6 imported by those.
180
+ * omakit could not read it. Measured at pins b7b29654 and b441b4f0: 16 of
181
+ * the 34 files under scripts/, 10 opened by omakit and 6 imported by those.
169
182
  *
170
183
  * @returns {{ path: string, imported: boolean, via: string|null }[]}
171
184
  */
@@ -243,6 +256,58 @@ export function policyConstants(text) {
243
256
  }
244
257
  }
245
258
 
259
+ // The readers PIN_READS registers for the files read as text. Each takes
260
+ // the file's text and returns the value, or null where the text does not
261
+ // carry it, the way policyConstants() does for the policy module: no file
262
+ // is opened here and no module is loaded, so the same function reads the
263
+ // pin's copy for a check and HEAD's copy for doctor's comparison.
264
+
265
+ /**
266
+ * The reserved plugin-id namespace as the marketplace states it in
267
+ * `build-catalog.mjs`, next to its own `reserved-plugin-id` check, with
268
+ * its trailing dot. Read, not copied, so a pin update moves it.
269
+ */
270
+ export function reservedNamespace(text) {
271
+ const source = String(text || "")
272
+ const match = source.match(/startsWith\("([A-Za-z0-9.\-_]+\.)"\)\s*\)\s*\{\s*\n\s*checkError\("reserved-plugin-id"/)
273
+ || source.match(/checkError\("reserved-plugin-id",\s*`[^`]*\$\{[^}]*\}:\s*the ([A-Za-z0-9.\-_]+)\.\*\s+namespace is reserved`/)
274
+ if (!match) return null
275
+ return match[1].endsWith(".") ? match[1] : `${match[1]}.`
276
+ }
277
+
278
+ /**
279
+ * How the marketplace itself presents a plugin it lists, read out of
280
+ * `build-catalog.mjs` rather than copied: `categoryFor(kinds)` maps the
281
+ * manifest's kinds to a category in order of its `if` lines, with a
282
+ * fallback, and the tags are the first three kinds lowercased. `omakit
283
+ * submit` offers these as the default answer when it has to ask for a
284
+ * category and tags, so the default is the marketplace's own choice; a
285
+ * mapping that cannot be read offers no default. tests/unit/registry-
286
+ * figures.test.mjs pins what the mapping is at this commit, so a
287
+ * marketplace that changes it fails the suite until the docs follow.
288
+ *
289
+ * @returns {{ rules: Array<{ kinds: string[], category: string }>, fallback: string|null, tagsFromKinds: boolean }}
290
+ */
291
+ export function catalogPresentation(text) {
292
+ const source = String(text || "")
293
+ const body = source.match(/function categoryFor\([^)]*\)\s*\{([\s\S]*?)\n\}/)?.[1] || ""
294
+ const rules = [...body.matchAll(/if \(((?:kinds\.includes\("[^"]+"\)(?:\s*\|\|\s*)?)+)\) return "([^"]+)";/g)]
295
+ .map((match) => ({ kinds: [...match[1].matchAll(/"([^"]+)"/g)].map((kind) => kind[1]), category: match[2] }))
296
+ const fallback = body.match(/\n\s*return "([^"]+)";\s*$/)?.[1] || null
297
+ const tagsFromKinds = /tags:\s*kinds\.slice\(0,\s*3\)\.map\(\(kind\) => kind\.toLowerCase\(\)\)/.test(source)
298
+ return { rules, fallback, tagsFromKinds }
299
+ }
300
+
301
+ /** The label `approve-submission.mjs` requires beside `submission` before it approves: the one `omakit watch` reads as validated. */
302
+ export function validatedLabel(text) {
303
+ return String(text || "").match(/for \(const required of \["submission", "([^"]+)"/)?.[1] || null
304
+ }
305
+
306
+ /** The first label `approve-plugin-update.mjs` requires: the one an update issue carries, for review cost. */
307
+ export function updateLabel(text) {
308
+ return String(text || "").match(/for \(const required of \["([^"]+)"/)?.[1] || null
309
+ }
310
+
246
311
  /** Identity of the checkout at `dir`: commit plus the policy constants read from the pinned source. */
247
312
  function readPinIdentity(dir) {
248
313
  const commit = git(dir, ["rev-parse", "HEAD"]).trim()
@@ -3,8 +3,8 @@
3
3
  // its outcome will cause on submission.
4
4
  //
5
5
  // Measured reason this runs before submitting (docs/MEASUREMENTS.md M4): of the
6
- // 3,619 listed sources with a recorded baseline at the pin, it produced 2,031
7
- // `passed`, 1,562 `review-required` and 26 `needs-fixes` (counted by
6
+ // 4,449 listed sources with a recorded baseline at the pin, it produced 2,451
7
+ // `passed`, 1,962 `review-required` and 36 `needs-fixes` (counted by
8
8
  // registry.mjs baselineFigures, pinned by tests/unit/registry-figures.test.mjs). `review-required` is not a defect and
9
9
  // needs no source change, but it does mean a human must look, and it is the
10
10
  // single largest determinant of whether a submission waits on a person. An
@@ -26,7 +26,7 @@
26
26
 
27
27
  import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
28
28
  import { dirname, join } from "node:path"
29
- import { MARKETPLACE_PIN, POLICY_MODULE, requirePin } from "./pin.mjs"
29
+ import { MARKETPLACE_PIN, PIN_READS, POLICY_MODULE, catalogPresentation, requirePin, reservedNamespace } from "./pin.mjs"
30
30
  import { defaultBranchHead, getJson } from "./github.mjs"
31
31
  import { omakitCacheDir } from "./paths.mjs"
32
32
 
@@ -38,12 +38,14 @@ export const CATALOG_BUILDER_PATH = "scripts/build-catalog.mjs"
38
38
  export const LIVE_PATHS = Object.freeze([REGISTRY_PATH, CATALOG_PATH])
39
39
 
40
40
  /**
41
- * The one file read from HEAD as text and used for nothing: `omakit doctor`
42
- * reads the policy module at HEAD to compare its two constants with the
43
- * pin's (pin.mjs policyConstants) and drops the text. It is never imported,
44
- * never cached and never a source of a rule; the rules stay the pin's.
41
+ * The files read from HEAD as text and used for nothing but a comparison:
42
+ * `omakit doctor` reads the policy module at HEAD to compare its two
43
+ * constants with the pin's (pin.mjs policyConstants), and each file
44
+ * PIN_READS reads as text to compare the values its `readers` take out of
45
+ * it, and drops the text. None is ever imported, cached or a source of a
46
+ * rule; the rules stay the pin's.
45
47
  */
46
- export const HEAD_TEXT_PATHS = Object.freeze([POLICY_MODULE])
48
+ export const HEAD_TEXT_PATHS = Object.freeze([POLICY_MODULE, ...PIN_READS.filter((read) => read.readers).map((read) => read.path)])
47
49
 
48
50
  export class RegistryError extends Error {
49
51
  constructor(code, message) {
@@ -62,45 +64,17 @@ function readJson(pinDir, relative) {
62
64
  }
63
65
 
64
66
  /**
65
- * The reserved namespace as the marketplace states it, read from the pinned
66
- * `build-catalog.mjs` next to its own `reserved-plugin-id` check. Read, not
67
- * copied, so a pin update moves it.
67
+ * The text of the pinned catalog builder, the one file under scripts/ this
68
+ * module reads: never imported (it imports `sharp`), read for the two
69
+ * values PIN_READS registers for it, `reservedNamespace` and
70
+ * `catalogPresentation` (pin.mjs). Those two are re-exported here, where
71
+ * the checks that use them have always found them.
68
72
  */
69
- export function reservedNamespace(pinDir) {
70
- const source = readFileSync(join(pinDir, CATALOG_BUILDER_PATH), "utf8")
71
- const match = source.match(/startsWith\("([A-Za-z0-9.\-_]+\.)"\)\s*\)\s*\{\s*\n\s*checkError\("reserved-plugin-id"/)
72
- || source.match(/checkError\("reserved-plugin-id",\s*`[^`]*\$\{[^}]*\}:\s*the ([A-Za-z0-9.\-_]+)\.\*\s+namespace is reserved`/)
73
- if (!match) {
74
- throw new RegistryError(
75
- "pin-unreadable",
76
- `cannot read the reserved plugin-id namespace from ${CATALOG_BUILDER_PATH} at the pin; refusing to guess it`,
77
- )
78
- }
79
- return match[1].endsWith(".") ? match[1] : `${match[1]}.`
73
+ export function catalogBuilderText(pinDir) {
74
+ return readFileSync(join(pinDir, CATALOG_BUILDER_PATH), "utf8")
80
75
  }
81
76
 
82
- /**
83
- * How the marketplace itself presents a plugin it lists, read out of the
84
- * pinned `build-catalog.mjs` rather than copied: `categoryFor(kinds)` maps the
85
- * manifest's kinds to a category in order of its `if` lines, with a fallback,
86
- * and the tags are the first three kinds lowercased. `omakit submit` offers
87
- * these as the default answer when it has to ask for a category and tags, so
88
- * the default is the marketplace's own choice; a mapping that cannot be read
89
- * offers no default. tests/unit/registry-figures.test.mjs pins what the
90
- * mapping is at this commit, so a marketplace that changes it fails the suite
91
- * until the docs follow.
92
- *
93
- * @returns {{ rules: Array<{ kinds: string[], category: string }>, fallback: string|null, tagsFromKinds: boolean }}
94
- */
95
- export function catalogPresentation(pinDir) {
96
- const source = readFileSync(join(pinDir, CATALOG_BUILDER_PATH), "utf8")
97
- const body = source.match(/function categoryFor\([^)]*\)\s*\{([\s\S]*?)\n\}/)?.[1] || ""
98
- const rules = [...body.matchAll(/if \(((?:kinds\.includes\("[^"]+"\)(?:\s*\|\|\s*)?)+)\) return "([^"]+)";/g)]
99
- .map((match) => ({ kinds: [...match[1].matchAll(/"([^"]+)"/g)].map((kind) => kind[1]), category: match[2] }))
100
- const fallback = body.match(/\n\s*return "([^"]+)";\s*$/)?.[1] || null
101
- const tagsFromKinds = /tags:\s*kinds\.slice\(0,\s*3\)\.map\(\(kind\) => kind\.toLowerCase\(\)\)/.test(source)
102
- return { rules, fallback, tagsFromKinds }
103
- }
77
+ export { catalogPresentation, reservedNamespace }
104
78
 
105
79
  /**
106
80
  * The marketplace's own presentation for a manifest's kinds: the category its
@@ -149,8 +123,8 @@ export function sameRepository(a, b) {
149
123
  * a branch name, so the two files always come from the same commit and the
150
124
  * commit named in the output is the one they came from; never a path outside
151
125
  * LIVE_PATHS, so nothing executable can arrive this way. (headTextUrl below
152
- * reaches the same host for one module's text, which is compared and never
153
- * run.)
126
+ * reaches the same host for the text of the files doctor compares, which is
127
+ * compared and never run.)
154
128
  */
155
129
  export function liveFileUrl(commit, path) {
156
130
  if (!/^[0-9a-f]{40}$/.test(String(commit))) {
@@ -163,17 +137,17 @@ export function liveFileUrl(commit, path) {
163
137
  }
164
138
 
165
139
  /**
166
- * The same URL shape for the one file doctor reads from HEAD as text:
140
+ * The same URL shape for the files doctor reads from HEAD as text:
167
141
  * HEAD_TEXT_PATHS, at a 40-character commit, through the same host and the
168
- * same GET call site. Its text is compared and dropped; `liveFileUrl` still
169
- * refuses it, so nothing reads it as data.
142
+ * same GET call site. Their text is compared and dropped; `liveFileUrl`
143
+ * still refuses them, so nothing reads them as data.
170
144
  */
171
145
  export function headTextUrl(commit, path) {
172
146
  if (!/^[0-9a-f]{40}$/.test(String(commit))) {
173
147
  throw new RegistryError("usage", `a marketplace file is read at a 40-character commit, not "${commit}"`)
174
148
  }
175
149
  if (!HEAD_TEXT_PATHS.includes(path)) {
176
- throw new RegistryError("usage", `${path} is never read from HEAD as text; only ${HEAD_TEXT_PATHS.join(" and ")} ${HEAD_TEXT_PATHS.length === 1 ? "is" : "are"}`)
150
+ throw new RegistryError("usage", `${path} is never read from HEAD as text; only ${HEAD_TEXT_PATHS.join(", ")} are`)
177
151
  }
178
152
  return rawFileUrl(commit, path)
179
153
  }
@@ -371,9 +345,17 @@ export function idUniverse(options = {}) {
371
345
  throw new RegistryError("pin-unreadable", "the pinned catalog and registry produced no listed ids or repositories")
372
346
  }
373
347
 
348
+ const reservedPrefix = reservedNamespace(catalogBuilderText(pinDir))
349
+ if (!reservedPrefix) {
350
+ throw new RegistryError(
351
+ "pin-unreadable",
352
+ `cannot read the reserved plugin-id namespace from ${CATALOG_BUILDER_PATH} at the pin; refusing to guess it`,
353
+ )
354
+ }
355
+
374
356
  return {
375
357
  pinDir,
376
- reservedPrefix: reservedNamespace(pinDir),
358
+ reservedPrefix,
377
359
  listedIds,
378
360
  retiredIds,
379
361
  listedRepositories,
@@ -2,7 +2,7 @@
2
2
  import { readFileSync } from "node:fs"
3
3
  import { join } from "node:path"
4
4
  import { pathToFileURL } from "node:url"
5
- import { requirePin } from "./pin.mjs"
5
+ import { requirePin, updateLabel as updateLabelOf } from "./pin.mjs"
6
6
  import { token, issue, parseIssueUrl, compareCommits } from "./github.mjs"
7
7
  import { discoverWatchIssues, repositoryFor } from "./watch.mjs"
8
8
  import { repositorySlug } from "./registry.mjs"
@@ -12,8 +12,7 @@ import { submissionContract } from "./form.mjs"
12
12
  export async function reviewPolicy(repoRoot) {
13
13
  const { dir } = requirePin(repoRoot)
14
14
  const policy = await import(pathToFileURL(join(dir, "scripts/security-baseline-policy.mjs")).href)
15
- const approval = readFileSync(join(dir, "scripts/approve-plugin-update.mjs"), "utf8")
16
- const updateLabel = approval.match(/for \(const required of \["([^"]+)"/)?.[1]
15
+ const updateLabel = updateLabelOf(readFileSync(join(dir, "scripts/approve-plugin-update.mjs"), "utf8"))
17
16
  if (!updateLabel) throw new Error("cannot read the update issue label from the pin")
18
17
  const manual = policy.currentSecurityBaselinePolicy.maintainerVerificationOutcome
19
18
  return { automated: policy.securityBaselineOutcome([], []), manual, updateLabel, reviewLabel: `security-${manual}` }
@@ -14,7 +14,7 @@
14
14
  import { resolveSubject, SubjectError } from "../subject/resolve.mjs"
15
15
  import { requirePin } from "./pin.mjs"
16
16
  import { submissionContract, newerCommitChoice, resolveCategory, resolveTags, tagSlug } from "./form.mjs"
17
- import { idUniverse, checkIdentity, listingOf, baselineFigures, figure, liveRegistry, registrySourceDetail, catalogPresentation, defaultPresentation } from "./registry.mjs"
17
+ import { idUniverse, checkIdentity, listingOf, baselineFigures, figure, liveRegistry, registrySourceDetail, catalogBuilderText, catalogPresentation, defaultPresentation } from "./registry.mjs"
18
18
  import { readTree } from "./tree.mjs"
19
19
  import { inspectTree } from "./plugin.mjs"
20
20
  import { findAgentControl, REMEDY as AGENT_CONTROL_REMEDY } from "./agent-control.mjs"
@@ -283,7 +283,7 @@ export async function submitPreflight(options) {
283
283
  error.usage = missing
284
284
  throw error
285
285
  }
286
- const defaults = defaultPresentation(catalogPresentation(pinDir), tree.manifest?.kinds)
286
+ const defaults = defaultPresentation(catalogPresentation(catalogBuilderText(pinDir)), tree.manifest?.kinds)
287
287
  const answers = await options.chooser({ contract, defaults, missing: missing.missing })
288
288
  if (answers?.category) chosenCategory = answers.category
289
289
  if (answers?.tags) chosenTags = answers.tags
@@ -21,7 +21,7 @@
21
21
  import { join } from "node:path"
22
22
  import { pathToFileURL } from "node:url"
23
23
  import { readFileSync } from "node:fs"
24
- import { MARKETPLACE_PIN, requirePin } from "./pin.mjs"
24
+ import { MARKETPLACE_PIN, requirePin, validatedLabel } from "./pin.mjs"
25
25
  import { authenticatedUser, repositoryIssues, defaultBranchHead, issue, issueComments, parseIssueUrl, token, GitHubError } from "./github.mjs"
26
26
  import { liveRegistry } from "./registry.mjs"
27
27
  import { reviewPolicy, validatedDocumentationDiff } from "./review-cost.mjs"
@@ -266,8 +266,7 @@ export function validationComment(comments, feedback = []) {
266
266
  */
267
267
  export async function marketplaceLabels(pinDir) {
268
268
  const policy = await import(pathToFileURL(join(pinDir, "scripts/security-baseline-policy.mjs")).href)
269
- const approval = readFileSync(join(pinDir, "scripts/approve-submission.mjs"), "utf8")
270
- const validated = approval.match(/for \(const required of \["submission", "([^"]+)"/)?.[1]
269
+ const validated = validatedLabel(readFileSync(join(pinDir, "scripts/approve-submission.mjs"), "utf8"))
271
270
  if (!validated) throw new Error("cannot read the validated label from the pin")
272
271
  const manual = policy.currentSecurityBaselinePolicy.maintainerVerificationOutcome
273
272
  return { blocking: [...policy.securityBaselineBlockingLabels], validated, reviewRequired: `security-${manual}` }