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 +1 -1
- package/tools/blocks/commit.json +2 -2
- package/tools/lab/guest.mjs +1 -1
- package/tools/lab/host.mjs +2 -2
- package/tools/lab/paths.mjs +1 -1
- package/tools/lab/pin.json +1 -1
- package/tools/lab/verify.mjs +2 -2
- package/tools/marketplace/README.md +5 -3
- package/tools/marketplace/cli.mjs +1 -1
- package/tools/marketplace/doctor.mjs +114 -17
- package/tools/marketplace/measure-pin-grades.mjs +119 -0
- package/tools/marketplace/measure-registry-figures.mjs +160 -0
- package/tools/marketplace/pin.mjs +92 -22
- package/tools/marketplace/preflight.mjs +2 -2
- package/tools/marketplace/registry.mjs +39 -53
- package/tools/marketplace/review-cost.mjs +2 -3
- package/tools/marketplace/submit.mjs +2 -2
- package/tools/marketplace/watch.mjs +2 -3
package/package.json
CHANGED
package/tools/blocks/commit.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"commit": "
|
|
3
|
-
"recordedAt": "2026-
|
|
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
|
}
|
package/tools/lab/guest.mjs
CHANGED
|
@@ -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
|
|
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()
|
package/tools/lab/host.mjs
CHANGED
|
@@ -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 (
|
|
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,
|
|
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
|
*/
|
package/tools/lab/paths.mjs
CHANGED
|
@@ -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
|
-
* (
|
|
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)
|
package/tools/lab/pin.json
CHANGED
|
@@ -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
|
|
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
|
}
|
package/tools/lab/verify.mjs
CHANGED
|
@@ -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 (
|
|
9
|
-
//
|
|
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 `
|
|
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
|
|
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,
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
* the
|
|
206
|
-
*
|
|
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 ||
|
|
229
|
-
:
|
|
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 (
|
|
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
|
-
? `${
|
|
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
|
|
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
|
|
9
|
-
// takes 2 seconds instead of 17
|
|
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: "
|
|
31
|
-
commitSubject: "Add
|
|
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
|
|
44
|
-
*
|
|
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
|
|
130
|
-
* the 13 executed files uses any of them (the three read
|
|
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
|
|
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
|
|
199
|
+
let source
|
|
182
200
|
try {
|
|
183
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
7
|
-
// `passed`, 1,
|
|
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
|
|
42
|
-
* reads the policy module at HEAD to compare its two
|
|
43
|
-
* pin's (pin.mjs policyConstants) and
|
|
44
|
-
*
|
|
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
|
|
66
|
-
*
|
|
67
|
-
*
|
|
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
|
|
70
|
-
|
|
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
|
|
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
|
|
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.
|
|
169
|
-
* refuses
|
|
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("
|
|
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
|
|
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;
|
|
395
|
-
*
|
|
396
|
-
* stale literal
|
|
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
|
|
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
|
|
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}` }
|