omakit 0.6.9 → 0.6.11

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.11",
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": "134073bfe9da1073721c0a7e9905c15dea0c7176",
3
+ "recordedAt": "2026-10-01T04:06:29.378Z",
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,23 +7,25 @@ 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. |
21
21
  | `review-cost.mjs` | The advisory review-cost verdict, shared account discovery, and path classification between dated validated snapshots. M4 and M9 carry its evidence. |
22
+ | `measure-registry-figures.mjs` | Reproduces the registry figures of M4 and M6 at the pin: `baselineFigures()` recorded per pin in `docs/evidence/registry-figures.json`, newest first, and rendered from that record into the literal block of `tests/unit/registry-figures.test.mjs`, the M4 block and M6 row of `docs/MEASUREMENTS.md` and the Registry facts row of `docs/UPSTREAM_CONTRACT.md`. Without `--write` it names the files not yet rendered from the record (exit 1 when one is stale); with it, it writes them. Every outcome, capability and rule name it prints is a key of the registry's data. |
23
+ | `measure-pin-grades.mjs` | Reproduces M7's grades: each marketplace commit in a range that touched `scripts/` or the form, graded against its parent by the shipped `comparePin()` and `pinFreshness()` three ways (tree, 0.6.6's rule; blob, 0.6.7's; value, the shipped one), every request answered from a local clone of the marketplace history, so it fetches and runs nothing. |
22
24
  | `measure-review-cost.mjs` | Reproduces M9 across the open update population at live marketplace HEAD, with compare sources and explicit skipped reasons in JSON. |
23
25
  | `issue.mjs` | Renders the issue the way the form would, then has the marketplace's own parser judge it. |
24
26
  | `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
27
  | `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}`. |
28
+ | `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
29
  | `watch.mjs` | The validation watch: validated commit versus current default-branch HEAD, and the one action that refreshes it. |
28
30
  | `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
31
  | `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) {
@@ -0,0 +1,119 @@
1
+ // M7 reproduction: every marketplace commit in a range that touched a pinned
2
+ // path other than the two data files (scripts/ and the form), graded as a
3
+ // change from its parent, the parent as the pin and the commit as HEAD,
4
+ // under the three comparisons doctor has shipped: tree, 0.6.6's (any such
5
+ // path's tree or blob id moved); blob, 0.6.7's (pinFreshness without the
6
+ // text-read values); value, the one since 0.6.10 (pinFreshness as it
7
+ // ships). It runs the shipped comparePin() and pinFreshness() against a
8
+ // local clone of the marketplace's history (a blob-filtered clone is
9
+ // enough) and answers each request the comparison would make from that
10
+ // clone, counting it: nothing is fetched by this script and nothing from
11
+ // the clone is run. Measured on 2026-10-01: over the seven such commits
12
+ // since 38060f89, the three grades it gives equal those of the v0.6.6 and
13
+ // v0.6.7 tags' own code (docs/evidence/pin-freshness/2026-10-01-grades.json).
14
+ import { execFileSync } from "node:child_process"
15
+ import { resolve } from "node:path"
16
+ import { pathToFileURL } from "node:url"
17
+ import { PIN_PATHS, POLICY_MODULE, pinnedReadSet, policyConstants } from "./pin.mjs"
18
+ import { LIVE_PATHS } from "./registry.mjs"
19
+ import { comparePin, pinFreshness } from "./doctor.mjs"
20
+
21
+ /** The pinned paths a grade can turn on: PIN_PATHS less the two data files read live. */
22
+ export const GRADED_PATHS = Object.freeze(PIN_PATHS.map((pattern) => pattern.replace(/^\/|\/$/g, "")).filter((path) => !LIVE_PATHS.includes(path)))
23
+
24
+ /**
25
+ * The two kinds of request comparePin() makes, answered from the clone: a
26
+ * tree read by `git ls-tree`, a file's text at a commit by `git show`.
27
+ * `requests` lists each, in order.
28
+ */
29
+ export function cloneTransport(clone) {
30
+ const git = (...args) => execFileSync("git", ["-C", clone, ...args], { timeout: 60_000, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 })
31
+ const requests = []
32
+ const fetchJson = async (url) => {
33
+ requests.push(url)
34
+ const sha = String(url).match(/\/git\/trees\/([0-9a-f]{40})$/)?.[1]
35
+ if (!sha) throw new Error(`not a tree read at an object id: ${url}`)
36
+ const tree = git("ls-tree", sha).split("\n").filter(Boolean).map((line) => {
37
+ const [meta, path] = line.split("\t")
38
+ const [, type, id] = meta.split(" ")
39
+ return { path, type, sha: id }
40
+ })
41
+ return { sha, tree, truncated: false }
42
+ }
43
+ const fetchText = async (url) => {
44
+ requests.push(url)
45
+ const [, commit, path] = String(url).match(/\/([0-9a-f]{40})\/(.+)$/) || []
46
+ if (!commit) throw new Error(`not a file read at a commit: ${url}`)
47
+ return git("show", `${commit}:${path}`)
48
+ }
49
+ return { git, requests, fetchJson, fetchText }
50
+ }
51
+
52
+ /** One change graded three ways, with the read set resolved at `pin` and what the value comparison says of it. */
53
+ export async function gradeChange(clone, pin, head) {
54
+ const transport = cloneTransport(clone)
55
+ const reads = pinnedReadSet(clone, undefined, (path) => transport.git("show", `${pin}:${path}`))
56
+ const identity = { commit: pin, ...policyConstants(transport.git("show", `${pin}:${POLICY_MODULE}`)) }
57
+ const comparison = await comparePin({ pinDir: clone, headCommit: head, pinCommit: pin, fetchJson: transport.fetchJson, fetchText: transport.fetchText, reads })
58
+ const at = { commit: head, branch: null }
59
+ const value = pinFreshness(identity, at, comparison)
60
+ const blob = pinFreshness(identity, at, { ...comparison, textReads: {} })
61
+ return {
62
+ readSet: reads.map((read) => read.path),
63
+ requests: transport.requests.length,
64
+ tree: value.evidence.pinned.length ? "advice" : "ok",
65
+ blob: blob.state,
66
+ value: value.state,
67
+ detail: value.detail,
68
+ effective: value.evidence.effective,
69
+ textReads: value.evidence.textReads,
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Every commit in `since..until` that touched GRADED_PATHS, oldest first,
75
+ * each graded by gradeChange() against its first parent, and how many of
76
+ * those that touched a file in the read set each comparison grades advice.
77
+ */
78
+ export async function measurePinGrades({ clone, since, until }) {
79
+ const git = (...args) => execFileSync("git", ["-C", clone, ...args], { timeout: 60_000, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 })
80
+ const range = `${git("rev-parse", since).trim()}..${git("rev-parse", until).trim()}`
81
+ const listed = git("log", "--reverse", "--format=%H%x09%cI%x09%s", range, "--", ...GRADED_PATHS).split("\n").filter(Boolean)
82
+ const commits = []
83
+ for (const line of listed) {
84
+ const [commit, committedAt, subject] = line.split("\t")
85
+ const parent = git("rev-parse", `${commit}^`).trim()
86
+ const touched = git("log", "-1", "--format=", "--name-only", commit, "--", ...GRADED_PATHS).split("\n").filter(Boolean)
87
+ const { readSet, ...graded } = await gradeChange(clone, parent, commit)
88
+ commits.push({ commit, committedAt, subject, parent, touched, inReadSet: touched.filter((path) => readSet.includes(path)), ...graded })
89
+ }
90
+ const touching = commits.filter((row) => row.inReadSet.length)
91
+ const advice = (rows, rule) => rows.filter((row) => row[rule] === "advice").length
92
+ return {
93
+ measurement: "M7",
94
+ measuredAt: new Date().toISOString(),
95
+ range,
96
+ gradedPaths: GRADED_PATHS,
97
+ method: "Each commit in the range that touched a graded path, graded against its first parent by the shipped comparePin() and pinFreshness(), every request answered from the local clone: tree is 0.6.6's rule (a graded path's object id moved), blob 0.6.7's (pinFreshness without text-read values), value the shipped grade.",
98
+ commits,
99
+ touchingReadSet: touching.length,
100
+ advice: { tree: advice(touching, "tree"), blob: advice(touching, "blob"), value: advice(touching, "value") },
101
+ adviceOverAll: { commits: commits.length, tree: advice(commits, "tree"), blob: advice(commits, "blob"), value: advice(commits, "value") },
102
+ }
103
+ }
104
+
105
+ if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
106
+ const args = process.argv.slice(2)
107
+ const option = (name) => {
108
+ const index = args.indexOf(name)
109
+ return index >= 0 ? args[index + 1] : undefined
110
+ }
111
+ const clone = option("--clone")
112
+ const since = option("--since")
113
+ const until = option("--until") || "HEAD"
114
+ if (!clone || !since || args.length !== (option("--until") ? 6 : 4)) {
115
+ process.stderr.write("usage: node tools/marketplace/measure-pin-grades.mjs --clone <marketplace clone> --since <commit> [--until <commit>]\n")
116
+ process.exit(2)
117
+ }
118
+ process.stdout.write(`${JSON.stringify(await measurePinGrades({ clone: resolve(clone), since, until }), null, 2)}\n`)
119
+ }
@@ -0,0 +1,160 @@
1
+ // M4 and M6 reproduction: the registry figures at the pin, recorded per pin
2
+ // in docs/evidence/registry-figures.json and rendered from that one record
3
+ // into the three places that cite them: the literal block in
4
+ // tests/unit/registry-figures.test.mjs, the M4 block and the M6 row of
5
+ // docs/MEASUREMENTS.md, and the Registry facts row of
6
+ // docs/UPSTREAM_CONTRACT.md. Measured on the 0.6.10 bump: the same
7
+ // figures were retyped by hand in nine files, and four of them
8
+ // (preflight.mjs, MARKETPLACE.md, HOW.md, INSPECT_DESIGN.md) no test reads.
9
+ // Without --write it prints the record entry for the pin and names each
10
+ // file that is not yet rendered from it; with --write it records the entry
11
+ // and rewrites those files.
12
+ //
13
+ // Every name in the output (outcomes, capabilities, finding rules) is a key
14
+ // of the registry's own data, never typed here.
15
+ import { execFileSync } from "node:child_process"
16
+ import { readFileSync, writeFileSync } from "node:fs"
17
+ import { join, resolve } from "node:path"
18
+ import { fileURLToPath, pathToFileURL } from "node:url"
19
+ import { MARKETPLACE_PIN, requirePin } from "./pin.mjs"
20
+ import { baselineFigures, figure } from "./registry.mjs"
21
+
22
+ const ROOT = resolve(fileURLToPath(new URL("../..", import.meta.url)))
23
+
24
+ export const RECORD_PATH = "docs/evidence/registry-figures.json"
25
+ export const TEST_PATH = "tests/unit/registry-figures.test.mjs"
26
+ export const MEASUREMENTS_PATH = "docs/MEASUREMENTS.md"
27
+ export const CONTRACT_PATH = "docs/UPSTREAM_CONTRACT.md"
28
+
29
+ const TEST_BLOCK = /\/\/ registry-figures:begin[^\n]*\n[\s\S]*?\/\/ registry-figures:end\n/
30
+ const M4_BLOCK = /<!-- registry-figures:m4 -->\n[\s\S]*?<!-- \/registry-figures:m4 -->\n/
31
+ const M6_ROW = /^\| Listed sources whose validated commit was superseded at least once \|.*\|$/m
32
+ const CONTRACT_ROW = /^\| Registry facts \|.*\|$/m
33
+
34
+ /** The record entry for the pinned checkout: its commit, when it was committed, and baselineFigures() over it. */
35
+ export function entryAtPin(pinDir) {
36
+ const commit = execFileSync("git", ["-C", pinDir, "rev-parse", "HEAD"], { timeout: 60_000, encoding: "utf8" }).trim()
37
+ const committedAt = execFileSync("git", ["-C", pinDir, "log", "-1", "--format=%cI", "HEAD"], { timeout: 60_000, encoding: "utf8" }).trim()
38
+ return { commit, committedAt: new Date(committedAt).toISOString().replace(/\.000Z$/, "Z"), figures: baselineFigures({ pinDir }) }
39
+ }
40
+
41
+ /** The record with `entry` first, replacing an entry for the same commit; the others keep their order, newest first. */
42
+ export function upsert(record, entry) {
43
+ return { ...record, pins: [entry, ...record.pins.filter((pin) => pin.commit !== entry.commit)] }
44
+ }
45
+
46
+ /** A table of counts, largest first and by name on a tie: the order every list here is printed in. */
47
+ const ranked = (table) => Object.entries(table).sort(([a, x], [b, y]) => y - x || a.localeCompare(b))
48
+ const short = (commit) => commit.slice(0, 8)
49
+ const when = (iso) => `${iso.slice(0, 10)} ${iso.slice(11, 16)} UTC`
50
+ const percent = (part, whole) => ((part / whole) * 100).toFixed(1)
51
+
52
+ /** Prose wrapped at 76 columns, the way the documents are written. */
53
+ function wrap(text, width = 76) {
54
+ const lines = []
55
+ let line = ""
56
+ for (const word of text.split(" ")) {
57
+ if (line && line.length + 1 + word.length > width) {
58
+ lines.push(line)
59
+ line = word
60
+ } else {
61
+ line = line ? `${line} ${word}` : word
62
+ }
63
+ }
64
+ if (line) lines.push(line)
65
+ return lines.join("\n")
66
+ }
67
+
68
+ /** The literal block the figures test holds the pin to. */
69
+ export function renderTest(text, record) {
70
+ const [current] = record.pins
71
+ const block = [
72
+ `// registry-figures:begin rendered from ${RECORD_PATH} by \`node tools/marketplace/measure-registry-figures.mjs --write\`; change the record, not this block`,
73
+ `const AT_PIN = Object.freeze(${JSON.stringify({ commit: current.commit, figures: current.figures }, null, 2)})`,
74
+ "// registry-figures:end",
75
+ "",
76
+ ].join("\n")
77
+ if (!TEST_BLOCK.test(text)) throw new Error(`${TEST_PATH} has no registry-figures block to render into`)
78
+ return text.replace(TEST_BLOCK, () => block)
79
+ }
80
+
81
+ /** M4's figures and the earlier pins' table, and M6's superseded row. */
82
+ export function renderMeasurements(text, record) {
83
+ const [current, ...earlier] = record.pins
84
+ const f = current.figures
85
+ const outcomes = ranked(f.outcomes)
86
+ const lines = [
87
+ "<!-- registry-figures:m4 -->",
88
+ wrap(`From \`registry.json\` at the pin (\`${short(current.commit)}\`, ${when(current.committedAt)}): ${figure(f.sources)} listed sources, of which ${figure(f.withBaseline)} carry a recorded baseline (the rest were listed before the baseline existed or have no record):`),
89
+ "",
90
+ "| Outcome | Count |",
91
+ "| --- | --- |",
92
+ ...outcomes.map(([name, n]) => `| \`${name}\` | ${figure(n)} |`),
93
+ `| Findings ever recorded, total | ${figure(f.findingsTotal)} (${ranked(f.findings).map(([name, n]) => `\`${name}\` ${figure(n)}`).join(", ")}) |`,
94
+ "",
95
+ wrap(`Capabilities recorded: ${ranked(f.capabilities).map(([name, n]) => `${name} ${figure(n)}`).join(", ")}.`),
96
+ ]
97
+ if (earlier.length) {
98
+ lines.push(
99
+ "",
100
+ "At the earlier pins, counted the same way from the same two files:",
101
+ "",
102
+ `| Pin | Sources | With a baseline | ${outcomes.map(([name]) => `\`${name}\``).join(" | ")} |`,
103
+ `| --- | --- | --- | ${outcomes.map(() => "---").join(" | ")} |`,
104
+ ...earlier.map((pin) => `| \`${short(pin.commit)}\` (${when(pin.committedAt)}) | ${figure(pin.figures.sources)} | ${figure(pin.figures.withBaseline)} | ${outcomes.map(([name]) => figure(pin.figures.outcomes[name] || 0)).join(" | ")} |`),
105
+ )
106
+ }
107
+ lines.push("<!-- /registry-figures:m4 -->", "")
108
+ const superseded = (pin) => `${figure(pin.figures.superseded.sources)} of ${figure(pin.figures.sources)}, ${percent(pin.figures.superseded.sources, pin.figures.sources)}%, ${figure(pin.figures.superseded.commits)} commits`
109
+ const row = `| Listed sources whose validated commit was superseded at least once | ${figure(f.superseded.sources)} of ${figure(f.sources)} (${percent(f.superseded.sources, f.sources)}%) at pin \`${short(current.commit)}\`, ${figure(f.superseded.commits)} superseded commits, one source revalidated ${f.superseded.most} times${earlier.length ? ` (${earlier.map((pin) => `at pin \`${short(pin.commit)}\`: ${superseded(pin)}`).join("; ")})` : ""} |`
110
+ if (!M4_BLOCK.test(text)) throw new Error(`${MEASUREMENTS_PATH} has no registry-figures:m4 block to render into`)
111
+ if (!M6_ROW.test(text)) throw new Error(`${MEASUREMENTS_PATH} has no superseded row in M6 to render into`)
112
+ return text.replace(M4_BLOCK, () => lines.join("\n")).replace(M6_ROW, () => row)
113
+ }
114
+
115
+ /** The Registry facts row of the pinned-checkout table. */
116
+ export function renderContract(text, record) {
117
+ const f = record.pins[0].figures
118
+ const row = `| Registry facts | ${figure(f.listingValidated)} sources with \`listingValidatedCommit\`; ${figure(f.withBaseline)} with an \`automatedSecurityBaseline\` record (${ranked(f.outcomes).map(([name, n]) => `${figure(n)} ${name}`).join(", ")}); ${figure(f.catalogPlugins)} catalog plugin ids; ${figure(f.retiredIds)} retired ids; ${figure(f.superseded.sources)} sources (${percent(f.superseded.sources, f.sources)}%) with at least one superseded validated commit, ${figure(f.superseded.commits)} superseded commits in all |`
119
+ if (!CONTRACT_ROW.test(text)) throw new Error(`${CONTRACT_PATH} has no Registry facts row to render into`)
120
+ return text.replace(CONTRACT_ROW, () => row)
121
+ }
122
+
123
+ /** Each file the record renders into, as it is and as the record would have it. */
124
+ export function renderAll(record, read = (path) => readFileSync(join(ROOT, path), "utf8")) {
125
+ return [
126
+ { path: RECORD_PATH, now: read(RECORD_PATH), next: `${JSON.stringify(record, null, 2)}\n` },
127
+ { path: TEST_PATH, now: read(TEST_PATH), next: renderTest(read(TEST_PATH), record) },
128
+ { path: MEASUREMENTS_PATH, now: read(MEASUREMENTS_PATH), next: renderMeasurements(read(MEASUREMENTS_PATH), record) },
129
+ { path: CONTRACT_PATH, now: read(CONTRACT_PATH), next: renderContract(read(CONTRACT_PATH), record) },
130
+ ]
131
+ }
132
+
133
+ /**
134
+ * The pin's entry recorded and every file rendered from the record. With
135
+ * `write`, the files that differ are written; without, nothing is. Returns
136
+ * the entry and, per file, whether it was (or would be) rewritten.
137
+ */
138
+ export function measureRegistryFigures({ repoRoot = ROOT, pinDir = requirePin(repoRoot).dir, write = false } = {}) {
139
+ const entry = entryAtPin(pinDir)
140
+ if (entry.commit !== MARKETPLACE_PIN.commit) throw new Error(`the checkout at ${pinDir} is at ${entry.commit}, not the pin ${MARKETPLACE_PIN.commit}`)
141
+ const read = (path) => readFileSync(join(repoRoot, path), "utf8")
142
+ const record = upsert(JSON.parse(read(RECORD_PATH)), entry)
143
+ const files = renderAll(record, read)
144
+ if (write) {
145
+ for (const file of files.filter((candidate) => candidate.next !== candidate.now)) writeFileSync(join(repoRoot, file.path), file.next)
146
+ }
147
+ return { entry, files: Object.fromEntries(files.map((file) => [file.path, file.next === file.now ? "current" : write ? "written" : "stale"])) }
148
+ }
149
+
150
+ if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
151
+ const args = process.argv.slice(2)
152
+ const unknown = args.filter((arg) => arg !== "--write")
153
+ if (unknown.length) {
154
+ process.stderr.write(`usage: node tools/marketplace/measure-registry-figures.mjs [--write]; not ${unknown.join(" ")}\n`)
155
+ process.exit(2)
156
+ }
157
+ const result = measureRegistryFigures({ write: args.includes("--write") })
158
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`)
159
+ if (!args.includes("--write") && Object.values(result.files).includes("stale")) process.exitCode = 1
160
+ }
@@ -5,8 +5,9 @@
5
5
  // It fetches only what omakit reads. The marketplace at this commit is 325 MB,
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
- // 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
8
+ // blob-filtered, sparsely checked out fetch of just those paths was 15 MB 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,10 +28,13 @@ 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",
35
+ // What a fresh sparse fetch of this commit measured on disk, for the one
36
+ // line `omakit pin` prints before it fetches. A pin bump re-measures it.
37
+ fetchedMb: 23,
34
38
  })
35
39
 
36
40
  /**
@@ -40,8 +44,9 @@ export const MARKETPLACE_PIN = Object.freeze({
40
44
  * /scripts/ the official submission parser, baseline
41
45
  * scanner, policy, report and record modules,
42
46
  * and build-catalog.mjs read as text for the
43
- * reserved plugin-id namespace. Taken whole
44
- * because those modules import each other.
47
+ * reserved plugin-id namespace and the
48
+ * category mapping. Taken whole because
49
+ * those modules import each other.
45
50
  * /registry.json retired plugin ids, listed repositories
46
51
  * /site/catalog.json listed plugin ids
47
52
  * /.github/ISSUE_TEMPLATE/ the submission form: the whole contract
@@ -83,8 +88,19 @@ export const POLICY_MODULE = "scripts/security-baseline-policy.mjs"
83
88
  * approve-submission.mjs watch.mjs, text: one label
84
89
  * approve-plugin-update.mjs review-cost.mjs, text: one label
85
90
  *
91
+ * A file read as text carries `readers`: each value omakit takes out of
92
+ * it, by name, as a function from the file's text to the value. This is
93
+ * the one list of what is read out of such a file, and the functions are
94
+ * the ones the modules above call on the pin's text and the ones `omakit
95
+ * doctor` calls on the pin's text and on HEAD's, so the two sides of the
96
+ * comparison cannot read differently. Measured on 2026-10-01 (M7): the
97
+ * three commits that moved build-catalog.mjs since b7b29654 changed
98
+ * neither of the two values read out of it, and the blob comparison
99
+ * graded each of them advice.
100
+ *
86
101
  * 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.
102
+ * a read that is not here, or one listed the wrong way round, and holds
103
+ * `readers` to exactly the files read as text.
88
104
  */
89
105
  export const PIN_READS = Object.freeze([
90
106
  Object.freeze({ path: "scripts/submission.mjs", imported: true }),
@@ -94,9 +110,9 @@ export const PIN_READS = Object.freeze([
94
110
  Object.freeze({ path: "scripts/security-baseline-report.mjs", imported: true }),
95
111
  Object.freeze({ path: "scripts/security-baseline-record.mjs", imported: true }),
96
112
  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 }),
113
+ Object.freeze({ path: "scripts/build-catalog.mjs", imported: false, readers: Object.freeze({ reservedNamespace, catalogPresentation }) }),
114
+ Object.freeze({ path: "scripts/approve-submission.mjs", imported: false, readers: Object.freeze({ validatedLabel }) }),
115
+ Object.freeze({ path: "scripts/approve-plugin-update.mjs", imported: false, readers: Object.freeze({ updateLabel }) }),
100
116
  ])
101
117
 
102
118
  /**
@@ -126,9 +142,9 @@ export const RELATIVE_IMPORT = /\bfrom\s+["'](\.\.?\/[^"']+)["']/g
126
142
  * no guard; Node's ESM loader requires an extension.) If one of these ever
127
143
  * appears in an executed file of the pinned read set, pinnedReadSet() could
128
144
  * 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).
145
+ * instead of doctor staying quiet. Measured at pins b7b29654 and
146
+ * b441b4f0: none of the 13 executed files uses any of them (the three read
147
+ * as text are not scanned, since nothing they import is loaded).
132
148
  */
133
149
  export const UNFOLLOWED_IMPORTS = Object.freeze([
134
150
  Object.freeze({ form: "dynamic import()", pattern: /\bimport\s*\(/ }),
@@ -164,12 +180,14 @@ export function unfollowedImports(pinDir, reads = pinnedReadSet(pinDir)) {
164
180
  * the file that imports it or null for one omakit opens itself. A
165
181
  * specifier that leaves scripts/ (the marketplace's site/ assets) or names
166
182
  * 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.
183
+ * omakit could not read it. Measured at pins b7b29654 and b441b4f0: 16 of
184
+ * the 34 files under scripts/, 10 opened by omakit and 6 imported by those.
185
+ * `text` reads one file's text by its path; it defaults to the checkout in
186
+ * `pinDir`, and measure-pin-grades.mjs hands in a commit's blobs instead.
169
187
  *
170
188
  * @returns {{ path: string, imported: boolean, via: string|null }[]}
171
189
  */
172
- export function pinnedReadSet(pinDir, reads = PIN_READS) {
190
+ export function pinnedReadSet(pinDir, reads = PIN_READS, text = (path) => readFileSync(join(pinDir, path), "utf8")) {
173
191
  const set = new Map()
174
192
  const queue = []
175
193
  for (const read of reads) {
@@ -178,13 +196,13 @@ export function pinnedReadSet(pinDir, reads = PIN_READS) {
178
196
  }
179
197
  while (queue.length) {
180
198
  const from = queue.shift()
181
- let text
199
+ let source
182
200
  try {
183
- text = readFileSync(join(pinDir, from), "utf8")
201
+ source = text(from)
184
202
  } catch (error) {
185
203
  throw new PinError(`cannot read ${from} from the pinned checkout at ${pinDir}: ${error.message}`)
186
204
  }
187
- for (const match of text.matchAll(RELATIVE_IMPORT)) {
205
+ for (const match of source.matchAll(RELATIVE_IMPORT)) {
188
206
  const path = posix.normalize(posix.join(posix.dirname(from), match[1]))
189
207
  if (!path.startsWith("scripts/") || set.has(path)) continue
190
208
  set.set(path, { path, imported: true, via: from })
@@ -219,7 +237,7 @@ function pinMigration(repoRoot, env = process.env) {
219
237
  if (!existsSync(join(oldDir, ".git")) || existsSync(newDir)) return null
220
238
  const remedy = `mkdir -p -- ${shellQuote(dirname(newDir))} && mv -- ${shellQuote(oldDir)} ${shellQuote(newDir)}`
221
239
  return new PinError(
222
- `the marketplace pin is still at the old in-repository location ${oldDir}; the user-writable location ${newDir} does not exist. Omakit will not move the measured 15 MB checkout without you.`,
240
+ `the marketplace pin is still at the old in-repository location ${oldDir}; the user-writable location ${newDir} does not exist. Omakit will not move the checkout (${pinDiskUsage(oldDir, env)}) without you.`,
223
241
  { code: "marketplace-pin-migration-required", remedy },
224
242
  )
225
243
  }
@@ -243,6 +261,58 @@ export function policyConstants(text) {
243
261
  }
244
262
  }
245
263
 
264
+ // The readers PIN_READS registers for the files read as text. Each takes
265
+ // the file's text and returns the value, or null where the text does not
266
+ // carry it, the way policyConstants() does for the policy module: no file
267
+ // is opened here and no module is loaded, so the same function reads the
268
+ // pin's copy for a check and HEAD's copy for doctor's comparison.
269
+
270
+ /**
271
+ * The reserved plugin-id namespace as the marketplace states it in
272
+ * `build-catalog.mjs`, next to its own `reserved-plugin-id` check, with
273
+ * its trailing dot. Read, not copied, so a pin update moves it.
274
+ */
275
+ export function reservedNamespace(text) {
276
+ const source = String(text || "")
277
+ const match = source.match(/startsWith\("([A-Za-z0-9.\-_]+\.)"\)\s*\)\s*\{\s*\n\s*checkError\("reserved-plugin-id"/)
278
+ || source.match(/checkError\("reserved-plugin-id",\s*`[^`]*\$\{[^}]*\}:\s*the ([A-Za-z0-9.\-_]+)\.\*\s+namespace is reserved`/)
279
+ if (!match) return null
280
+ return match[1].endsWith(".") ? match[1] : `${match[1]}.`
281
+ }
282
+
283
+ /**
284
+ * How the marketplace itself presents a plugin it lists, read out of
285
+ * `build-catalog.mjs` rather than copied: `categoryFor(kinds)` maps the
286
+ * manifest's kinds to a category in order of its `if` lines, with a
287
+ * fallback, and the tags are the first three kinds lowercased. `omakit
288
+ * submit` offers these as the default answer when it has to ask for a
289
+ * category and tags, so the default is the marketplace's own choice; a
290
+ * mapping that cannot be read offers no default. tests/unit/registry-
291
+ * figures.test.mjs pins what the mapping is at this commit, so a
292
+ * marketplace that changes it fails the suite until the docs follow.
293
+ *
294
+ * @returns {{ rules: Array<{ kinds: string[], category: string }>, fallback: string|null, tagsFromKinds: boolean }}
295
+ */
296
+ export function catalogPresentation(text) {
297
+ const source = String(text || "")
298
+ const body = source.match(/function categoryFor\([^)]*\)\s*\{([\s\S]*?)\n\}/)?.[1] || ""
299
+ const rules = [...body.matchAll(/if \(((?:kinds\.includes\("[^"]+"\)(?:\s*\|\|\s*)?)+)\) return "([^"]+)";/g)]
300
+ .map((match) => ({ kinds: [...match[1].matchAll(/"([^"]+)"/g)].map((kind) => kind[1]), category: match[2] }))
301
+ const fallback = body.match(/\n\s*return "([^"]+)";\s*$/)?.[1] || null
302
+ const tagsFromKinds = /tags:\s*kinds\.slice\(0,\s*3\)\.map\(\(kind\) => kind\.toLowerCase\(\)\)/.test(source)
303
+ return { rules, fallback, tagsFromKinds }
304
+ }
305
+
306
+ /** The label `approve-submission.mjs` requires beside `submission` before it approves: the one `omakit watch` reads as validated. */
307
+ export function validatedLabel(text) {
308
+ return String(text || "").match(/for \(const required of \["submission", "([^"]+)"/)?.[1] || null
309
+ }
310
+
311
+ /** The first label `approve-plugin-update.mjs` requires: the one an update issue carries, for review cost. */
312
+ export function updateLabel(text) {
313
+ return String(text || "").match(/for \(const required of \["([^"]+)"/)?.[1] || null
314
+ }
315
+
246
316
  /** Identity of the checkout at `dir`: commit plus the policy constants read from the pinned source. */
247
317
  function readPinIdentity(dir) {
248
318
  const commit = git(dir, ["rev-parse", "HEAD"]).trim()
@@ -344,7 +414,7 @@ function populatePin(dir, log) {
344
414
  mkdirSync(dir, { recursive: true })
345
415
  execFileSync("git", ["init", "-q", dir], { timeout: 60_000, encoding: "utf8" })
346
416
  git(dir, ["remote", "add", "origin", MARKETPLACE_PIN.repository])
347
- log({ state: "fetching", text: `fetching the pinned marketplace checkout, ${MARKETPLACE_PIN.commit.slice(0, 7)}, about 15 MB` })
417
+ log({ state: "fetching", text: `fetching the pinned marketplace checkout, ${MARKETPLACE_PIN.commit.slice(0, 7)}, about ${MARKETPLACE_PIN.fetchedMb} MB` })
348
418
  git(dir, ["config", "core.sparseCheckout", "true"])
349
419
  mkdirSync(join(dir, ".git/info"), { recursive: true })
350
420
  writeFileSync(join(dir, ".git/info/sparse-checkout"), `${PIN_PATHS.join("\n")}\n`)
@@ -459,7 +529,7 @@ export function ensurePin(repoRoot, log = () => {}, env = process.env, { populat
459
529
  export function pinDiskUsage(dir, env = process.env) {
460
530
  // -H follows a symlink given on the command line (POSIX; GNU's -D). Measured
461
531
  // without it: a checkout reached through a symlink reported 0.0 MB, the size
462
- // of the link, while the directory behind it was 15 MB.
532
+ // of the link, while the directory behind it was 15 MB (at pin 38060f89).
463
533
  //
464
534
  // The total is read whether or not du exited 0. du exits 1 when a file it
465
535
  // listed is gone by the time it reaches it, and still prints the total.
@@ -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,
@@ -391,12 +373,13 @@ export function idUniverse(options = {}) {
391
373
  /**
392
374
  * The recorded baseline over every listed source, counted from registry.json at
393
375
  * the pin. This is the one place the figures cited by `baseline.preflight` and
394
- * docs/MEASUREMENTS.md M4 come from; tests/unit/registry-figures.test.mjs pins
395
- * the values, so a pin bump changes the printed number rather than leaving a
396
- * stale literal behind.
376
+ * docs/MEASUREMENTS.md M4 come from; measure-registry-figures.mjs records
377
+ * them per pin and renders the test and the two documents that cite them, so
378
+ * a pin bump changes the printed number rather than leaving a stale literal
379
+ * behind.
397
380
  *
398
381
  * @param {{ repoRoot?: string, pinDir?: string }} [options]
399
- * @returns {{ sources: number, withBaseline: number, outcomes: Record<string, number>,
382
+ * @returns {{ sources: number, listingValidated: number, withBaseline: number, outcomes: Record<string, number>,
400
383
  * capabilities: Record<string, number>, findings: Record<string, number>,
401
384
  * findingsTotal: number, superseded: { sources: number, commits: number, most: number },
402
385
  * retiredIds: number, catalogPlugins: number }}
@@ -411,11 +394,13 @@ export function baselineFigures(options = {}) {
411
394
  const capabilities = {}
412
395
  const findings = {}
413
396
  let withBaseline = 0
397
+ let listingValidated = 0
414
398
  let findingsTotal = 0
415
399
  let supersededSources = 0
416
400
  let supersededCommits = 0
417
401
  let most = 0
418
402
  for (const source of sources) {
403
+ if (typeof source?.listingValidatedCommit === "string" && source.listingValidatedCommit) listingValidated += 1
419
404
  const baseline = source?.automatedSecurityBaseline
420
405
  if (baseline && typeof baseline.outcome === "string") {
421
406
  withBaseline += 1
@@ -433,6 +418,7 @@ export function baselineFigures(options = {}) {
433
418
  }
434
419
  return {
435
420
  sources: sources.length,
421
+ listingValidated,
436
422
  withBaseline,
437
423
  outcomes,
438
424
  capabilities,
@@ -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}` }