omakit 0.6.6 → 0.6.7

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.6",
3
+ "version": "0.6.7",
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": "94cb1762be1d74aadbc884d9f86d2a8a0429b019",
3
- "recordedAt": "2026-09-21T19:08:27.689Z",
2
+ "commit": "5dd9db1969775b87b10f21445570557cf82e8073",
3
+ "recordedAt": "2026-09-21T20:24:57.368Z",
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
  }
@@ -7,14 +7,14 @@ local commit through the transport seam the marketplace tests itself
7
7
 
8
8
  | File | Purpose |
9
9
  | --- | --- |
10
- | `pin.mjs` | The pin identity (one home) and the reproducible setup: `omakit pin` fetches exactly that commit into `$XDG_CACHE_HOME/omakit/marketplace` and refuses a modified checkout. |
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. |
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 only way to the raw file host, at a 40-character commit, for those two files. `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 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. |
18
18
  | `tree.mjs` | The installable tree of a subject at one exact commit, from the Git object database. |
19
19
  | `plugin.mjs` | The root files the submission contract needs, and the declared plugin identity. |
20
20
  | `agent-control.mjs` | The recursive agent-control warning, and its remedy. |
@@ -23,7 +23,7 @@ local commit through the transport seam the marketplace tests itself
23
23
  | `issue.mjs` | Renders the issue the way the form would, then has the marketplace's own parser judge it. |
24
24
  | `submit.mjs` | Assembles every check with its measured reason, and withholds the body when a blocking check fails. Three outcomes: `ready` (the body), `refused` (a blocking check failed) and `listed` (the plugin is already listed by its own repository: `identity.available` passes with the listing's record, the five body checks are omitted rather than drawn as waiting, no body exists on purpose, and `listing` carries the listed commit against the local one and the form to use for a newer commit). Decides the category and tags after the registry: a listed plugin, own or taken, is asked for neither; an unlisted one without them is asked through `ask.mjs` at a terminal, and is a usage error otherwise. Ends with `reproduce`, the command line that repeats the run without asking. Under `--offline` the validation-commit check is `skipped`, not passed: verdict `skipped`, listed under `skipped` and not `unknown`, never blocking, and the READY line says "1 check skipped (--offline)". |
25
25
  | `ask.mjs` | The two questions `submit` asks a person at a terminal, and only there: category and tags, numbered from the pinned form, with the marketplace's own presentation for the manifest's kinds (read from the pinned catalog builder) as the default where it is on the list. Prompts on stderr, nothing persisted. |
26
- | `doctor.mjs` | What is installed, what is pinned, and what has moved. `omakit.version` is one check for one question, is this the current omakit: `ok` with "0.1.8, the newest published version", a `note` with "0.1.8; 0.1.9 is published" and the upgrade command, `unknown` with the registry's failure code, `info` offline; its evidence carries `{ installed, latest, source }`. `pin.freshness` compares each path in `PIN_PATHS` between the pin and the marketplace's HEAD by tree or blob id and splits what moved by the list `registry.mjs` reads live from: `registry.json` and `site/catalog.json` moving is `ok`, because those are read from HEAD anyway; anything else moving (`scripts/`, the two forms) is a `note` naming the paths, with an action a user can take, `omakit upgrade` and then an issue at the repository named in package.json. 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. |
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}`. |
27
27
  | `watch.mjs` | The validation watch: validated commit versus current default-branch HEAD, and the one action that refreshes it. |
28
28
  | `github.mjs` | Read-only GitHub access. GET only. The credential is your `gh` login, read through one frozen `gh auth token` call, and is never written anywhere. |
29
29
  | `style.mjs` | The visual system, defined once: the palette, the status vocabulary, the block ramp, the columns, the motion budgets, and the composition helpers every command draws with. Six states: `▁ ok`, `█ FAIL`, `▓ note`, `░ info`, `▒ ?` for a check that could not be made, and `▔ skip` for a check a flag said not to make, the floor's ink at the ceiling so it is never read as a pass. `docs/TUI.md` explains it. |
@@ -13,12 +13,13 @@
13
13
  // contract is read from, and the procedure in docs/UPSTREAM_CONTRACT.md requires
14
14
  // re-proving transport parity and committing the evidence afterwards. An
15
15
  // `upgrade` that quietly advanced the pin would break the one guarantee this
16
- // tool sells. So doctor reports that the pin is behind, and that is all it
17
- // does: the procedure is the maintainer's, it lives in that document and in
18
- // this comment, and it is never printed, because the person running doctor
19
- // is a user of a package that does not even ship docs/. What a user can do
20
- // is run `omakit upgrade`, since a newer omakit may already carry the new
21
- // pin, and otherwise open an issue naming the paths that moved.
16
+ // tool sells. So doctor reports what moved, graded, and that is all it does:
17
+ // the procedure is the maintainer's, it lives in that document and in this
18
+ // comment, and it is never printed, because the person running doctor is a
19
+ // user of a package that does not even ship docs/. What a user can do is run
20
+ // `omakit upgrade` when a newer omakit is published, since it may carry the
21
+ // new pin; otherwise the weekly pin-freshness workflow in this repository
22
+ // has already told the maintainer, and there is nothing to open.
22
23
  //
23
24
  // That the pin goes stale unnoticed is, of course, exactly the defect class
24
25
  // `omakit watch` exists to report. It would be poor form not to apply it here.
@@ -35,13 +36,31 @@
35
36
  // only registry.json and site/catalog.json moved, doctor said `note` and
36
37
  // pointed a user at docs/UPSTREAM_CONTRACT.md, though the pin was behind in
37
38
  // nothing the tool uses.
39
+ //
40
+ // Nor is "scripts/ moved" the same as "a file omakit reads moved". Measured
41
+ // on 2026-09-21 (M7): of the three marketplace commits that touched
42
+ // scripts/ since the first pin 38060f89, one (5e401552) changed only
43
+ // repository-identity.mjs, a file none of omakit's reads reach, and doctor
44
+ // graded the tree id's change as advice, a user read "run omakit upgrade",
45
+ // and the maintainer moved the pin for a verdict that could not change.
46
+ // So under scripts/ the comparison is by blob, over the 16 files
47
+ // pinnedReadSet() names, and the verdict is graded: `ok` when none of them
48
+ // moved, `info` when the only moved files are ones omakit takes wording or
49
+ // a label out of (WORDING_READS), `advice` when a file omakit executes or
50
+ // reads a rule from moved, a read file is gone at HEAD, or the form
51
+ // directory moved, which is the contract itself. The two policy constants
52
+ // are read at HEAD as text and printed beside the grade, so a person sees
53
+ // whether the baseline itself changed; they never soften the grade, since
54
+ // a rule can change without its version. The other two commits (7dd6e56,
55
+ // 40315f2) touched submission.mjs and the policy module, both executed,
56
+ // and the forms, so they grade advice either way.
38
57
 
39
58
  import { execFileSync } from "node:child_process"
40
59
  import { existsSync, readFileSync } from "node:fs"
41
60
  import { join } from "node:path"
42
- import { MARKETPLACE_PIN, PIN_PATHS, marketplacePinDir, pinDiskUsage, pinShape, requirePin } from "./pin.mjs"
43
- import { LIVE_PATHS } from "./registry.mjs"
44
- import { credential, defaultBranchHead, getJson, GitHubError } from "./github.mjs"
61
+ import { MARKETPLACE_PIN, PIN_PATHS, POLICY_MODULE, WORDING_READS, marketplacePinDir, pinDiskUsage, pinShape, pinnedReadSet, policyConstants, requirePin } from "./pin.mjs"
62
+ import { LIVE_PATHS, headTextUrl } from "./registry.mjs"
63
+ import { credential, defaultBranchHead, getJson, getText, GitHubError } from "./github.mjs"
45
64
  import { compareVersions, NPM_REGISTRY, registryLatest, upgradeCommand } from "./upgrade.mjs"
46
65
  import { sourceCommit } from "../blocks/add.mjs"
47
66
  import { recordedCommit } from "../blocks/record-commit.mjs"
@@ -50,19 +69,12 @@ import { completionStatus } from "./completion-check.mjs"
50
69
  import { inspectLab } from "../lab/inspect.mjs"
51
70
  import { labDoctorChecks } from "../lab/report.mjs"
52
71
 
53
- /** "git+https://github.com/owner/name.git" in package.json -> "https://github.com/owner/name", or null. */
54
- function repositoryPage(repository) {
55
- const url = typeof repository === "string" ? repository : repository?.url
56
- const match = String(url || "").match(/github\.com[/:]([^/]+)\/([^/]+?)(?:\.git)?\/?$/)
57
- return match ? `https://github.com/${match[1]}/${match[2]}` : null
58
- }
59
-
60
72
  export function tool(repoRoot) {
61
73
  try {
62
74
  const pkg = JSON.parse(readFileSync(join(repoRoot, "package.json"), "utf8"))
63
- return { name: pkg.name, version: pkg.version, engines: pkg.engines?.node || null, repository: repositoryPage(pkg.repository) }
75
+ return { name: pkg.name, version: pkg.version, engines: pkg.engines?.node || null }
64
76
  } catch {
65
- return { name: "omakit", version: "unknown", engines: null, repository: null }
77
+ return { name: "omakit", version: "unknown", engines: null }
66
78
  }
67
79
  }
68
80
 
@@ -80,17 +92,36 @@ function pinObjectId(pinDir, commit, path) {
80
92
  return execFileSync("git", ["-C", pinDir, "rev-parse", `${commit}:${path}`], { timeout: 60_000, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim()
81
93
  }
82
94
 
95
+ /** A PIN_PATHS pattern as a path: "/site/catalog.json" -> "site/catalog.json". */
96
+ const asPath = (pattern) => pattern.replace(/^\/|\/$/g, "")
97
+
98
+ /** The form directory: the whole contract, compared by tree id and graded advice when it moved. */
99
+ const FORMS = "/.github/ISSUE_TEMPLATE/"
100
+
83
101
  /**
84
- * Which of PIN_PATHS differ between the pin and `headCommit`. The pin side is
85
- * read from the local checkout; the HEAD side walks the git-trees API from the
86
- * exact commit, one read per tree on the way (three for PIN_PATHS as it
87
- * stands), so a directory compares by its tree id and a file by its blob id,
88
- * which is what "the blob at HEAD versus the blob at the pin" means for a
89
- * directory that omakit reads whole. A path missing at HEAD counts as changed.
102
+ * The pin against `headCommit`, path by path and, under scripts/, file by
103
+ * file. The pin side is read from the local checkout; the HEAD side walks
104
+ * the git-trees API from the exact commit, one read per tree on the way:
105
+ * three for PIN_PATHS as it stands, a fourth for the scripts/ tree only
106
+ * when its id moved, since an identical tree has identical blobs. Each
107
+ * PIN_PATHS entry compares by its own object id (a directory by tree id, a
108
+ * file by blob id) into `changedPaths`; each file in `reads`
109
+ * (pinnedReadSet by default) compares by blob id into `moved`, or
110
+ * `missing` when HEAD no longer has it. A PIN_PATHS entry missing at HEAD
111
+ * counts as changed.
90
112
  *
91
- * `fetchJson` is injectable for tests; the default is the one GET call site.
113
+ * When the policy module is among `moved`, its text at HEAD is read once
114
+ * through `fetchText` (the raw file host at the exact commit, the one GET
115
+ * call site; a fifth request) and its two constants are returned as
116
+ * `policyAtHead`; otherwise `policyAtHead` is null, because an identical
117
+ * blob has identical constants. The text is never imported.
118
+ *
119
+ * `fetchJson` and `fetchText` are injectable for tests.
120
+ *
121
+ * @returns {Promise<{ changedPaths: string[], reads: number, moved: string[], missing: string[],
122
+ * policyAtHead: { baselineVersion: string, enforcementMode: string }|null }>}
92
123
  */
93
- export async function changedPinPaths({ pinDir, headCommit, pinCommit = MARKETPLACE_PIN.commit, fetchJson = getJson }) {
124
+ export async function comparePin({ pinDir, headCommit, pinCommit = MARKETPLACE_PIN.commit, fetchJson = getJson, fetchText = getText, reads = pinnedReadSet(pinDir) }) {
94
125
  const match = MARKETPLACE_PIN.repository.match(/^https:\/\/github\.com\/([^/]+)\/([^/]+)$/)
95
126
  const trees = new Map()
96
127
  const tree = async (sha) => {
@@ -118,73 +149,140 @@ export async function changedPinPaths({ pinDir, headCommit, pinCommit = MARKETPL
118
149
  }
119
150
  return id
120
151
  }
121
- const changed = []
152
+ const changedPaths = []
122
153
  for (const pattern of PIN_PATHS) {
123
- const path = pattern.replace(/^\/|\/$/g, "")
124
- if (pinObjectId(pinDir, pinCommit, path) !== await headObjectId(path)) changed.push(pattern)
154
+ if (pinObjectId(pinDir, pinCommit, asPath(pattern)) !== await headObjectId(asPath(pattern))) changedPaths.push(pattern)
155
+ }
156
+ const moved = []
157
+ const missing = []
158
+ if (changedPaths.includes("/scripts/")) {
159
+ for (const { path } of reads) {
160
+ const atHead = await headObjectId(path)
161
+ if (atHead === null) missing.push(path)
162
+ else if (atHead !== pinObjectId(pinDir, pinCommit, path)) moved.push(path)
163
+ }
125
164
  }
126
- return changed
165
+ const policyAtHead = moved.includes(POLICY_MODULE) ? policyConstants(await fetchText(headTextUrl(headCommit, POLICY_MODULE))) : null
166
+ return { changedPaths, reads: reads.length, moved, missing, policyAtHead }
127
167
  }
128
168
 
129
- /** A PIN_PATHS pattern as a path: "/site/catalog.json" -> "site/catalog.json". */
130
- const asPath = (pattern) => pattern.replace(/^\/|\/$/g, "")
131
-
132
169
  /** "a", "a and b", "a, b and c". */
133
170
  function list(items) {
134
171
  return items.length < 3 ? items.join(" and ") : `${items.slice(0, -1).join(", ")} and ${items.at(-1)}`
135
172
  }
136
173
 
174
+ /** "baseline 3 (selective)". */
175
+ const policyText = (policy) => `baseline ${policy.baselineVersion} (${policy.enforcementMode})`
176
+
177
+ /** The moved files a verdict can depend on: everything omakit executes or reads a rule from, which is every read outside WORDING_READS. */
178
+ const verdictBearing = (paths) => paths.filter((path) => !WORDING_READS.includes(path))
179
+
137
180
  /**
138
- * The pin against the marketplace's HEAD, for a user. `changedPaths` is the
139
- * answer from changedPinPaths(), split by LIVE_PATHS into `readLive`, the
181
+ * The pin against the marketplace's HEAD, for a user, graded by what the
182
+ * difference can do to a verdict. `comparison` is the answer from
183
+ * comparePin(): its `changedPaths` split by LIVE_PATHS into `readLive`, the
140
184
  * data files a moved HEAD cannot make stale because registry.mjs reads them
141
- * from HEAD, and `pinned`, everything only a new pin can carry. Only the
142
- * second set is worth a note; the action for it is a user's, not the
143
- * maintainer's. Null when the comparison was not made, and then the detail
144
- * says only that HEAD moved. Full commits in the evidence; short ones in
145
- * the detail. `issues` is where a user reports a pinned path that moved,
146
- * read from package.json by the caller.
185
+ * from HEAD, and `pinned`, everything only a new pin can carry; its `moved`
186
+ * and `missing` name the files omakit reads under scripts/ whose blob
187
+ * differs at HEAD; its `policyAtHead` carries the two policy constants
188
+ * there when the policy module moved.
189
+ *
190
+ * ok nothing omakit reads moved: HEAD moved elsewhere, or only in
191
+ * the live-read data files, or scripts/ moved in none of the
192
+ * files omakit reads.
193
+ * info the only read files that moved are ones omakit takes wording
194
+ * or a label out of (WORDING_READS): what it prints may differ
195
+ * at HEAD, what it passes or refuses cannot. A newer omakit will
196
+ * carry the pin. Nothing for a user to do.
197
+ * advice a file omakit executes or reads a rule from moved (a verdict
198
+ * may differ at HEAD, whether or not the two policy constants
199
+ * still read the same, since a rule can change without its
200
+ * version), a read file is gone at HEAD, or the form directory
201
+ * moved (the contract itself). The action is `upgrade` (the
202
+ * command, when the caller found a newer omakit published), else
203
+ * that the maintainer is notified; never an issue to open, since
204
+ * the weekly pin-freshness workflow opens the one there is. The
205
+ * two constants are printed beside the grade in both cases.
206
+ *
207
+ * Null for `comparison` means the comparison was not made, which stays
208
+ * advice: HEAD moved and nothing here can say the pin is fine. Full commits
209
+ * in the evidence; short ones in the detail.
147
210
  */
148
- export function pinFreshness(identity, head, changedPaths = null, { issues = null } = {}) {
211
+ export function pinFreshness(identity, head, comparison = null, { upgrade = null } = {}) {
149
212
  const current = head.commit === identity.commit
150
213
  const branch = head.branch || "default"
151
- const changed = current ? [] : changedPaths || []
214
+ const compared = !current && comparison !== null
215
+ const changed = compared ? comparison.changedPaths : []
152
216
  const readLive = changed.filter((pattern) => LIVE_PATHS.includes(asPath(pattern)))
153
217
  const pinned = changed.filter((pattern) => !LIVE_PATHS.includes(asPath(pattern)))
218
+ const moved = compared ? comparison.moved : []
219
+ const missing = compared ? comparison.missing : []
220
+ const pinPolicy = { baselineVersion: identity.baselineVersion, enforcementMode: identity.enforcementMode }
221
+ const headPolicy = (compared && comparison.policyAtHead) || pinPolicy
222
+ const policyDiffers = headPolicy.baselineVersion !== pinPolicy.baselineVersion || headPolicy.enforcementMode !== pinPolicy.enforcementMode
223
+ const formMoved = pinned.includes(FORMS)
224
+ const verdictMoved = verdictBearing(moved)
225
+ const graded = moved.length > 0 || missing.length > 0 || formMoved
226
+ const state = current ? "ok"
227
+ : comparison === null || policyDiffers || formMoved || missing.length || verdictMoved.length ? "advice"
228
+ : moved.length ? "info"
229
+ : "ok"
230
+
154
231
  const where = `pin ${identity.commit.slice(0, 7)}; marketplace ${branch} at ${head.commit.slice(0, 7)}`
155
- const moved = changedPaths === null
156
- ? "; the paths omakit reads were not compared"
157
- : pinned.length
158
- ? `; changed since the pin: ${pinned.join(", ")}${readLive.length ? ` (${list(readLive.map(asPath))} moved too, and ${readLive.length === 1 ? "that is" : "those are"} read live)` : ""}`
159
- : readLive.length
160
- ? `; only ${list(readLive.map(asPath))} moved, and ${readLive.length === 1 ? "that is" : "those are"} read live`
161
- : "; nothing omakit reads moved"
162
- const stale = !current && (changedPaths === null || pinned.length > 0)
232
+ const clauses = []
233
+ if (comparison === null) clauses.push("the paths omakit reads were not compared")
234
+ else if (!changed.length) clauses.push("nothing omakit reads moved")
235
+ else {
236
+ if (moved.length) clauses.push(`moved since the pin: ${list(moved)}${verdictMoved.length ? "" : " (wording and labels only)"}`)
237
+ if (missing.length) clauses.push(`gone at HEAD: ${list(missing)}`)
238
+ if (formMoved) clauses.push(`the form moved (${asPath(FORMS)}/)`)
239
+ if (pinned.includes("/scripts/") && !moved.length && !missing.length) clauses.push(`scripts/ moved in none of the ${comparison.reads} files omakit reads`)
240
+ if (graded) clauses.push(policyDiffers ? `${policyText(pinPolicy)} at the pin, ${policyText(headPolicy)} at HEAD` : `${policyText(pinPolicy)} at both`)
241
+ }
242
+ const live = readLive.length ? `${list(readLive.map(asPath))} moved${clauses.length ? " too" : ""}, and ${readLive.length === 1 ? "that is" : "those are"} read live` : ""
243
+ const tail = !live ? "" : clauses.length ? ` (${live})` : `only ${live}`
244
+ const detail = current ? `the pin is the marketplace's current ${branch}-branch HEAD` : `${where}; ${clauses.join("; ")}${tail}`
245
+
246
+ const action = state === "info"
247
+ ? `${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.`
248
+ : state === "advice"
249
+ ? upgrade
250
+ ? `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.`
251
+ : "The maintainer is notified by the weekly pin-freshness run; a newer omakit will carry the pin. Until then every verdict here is the pin's, and the marketplace's own run on your issue is the one that counts."
252
+ : null
253
+
163
254
  return {
164
255
  id: "pin.freshness",
165
- state: stale ? "advice" : "ok",
166
- detail: current ? `the pin is the marketplace's current ${branch}-branch HEAD` : `${where}${moved}`,
167
- action: stale
168
- ? `A newer omakit may already carry the new pin: run \`omakit upgrade\`. If it does not, open an issue${issues ? ` at ${issues}/issues` : ""} naming the paths above.`
169
- : null,
256
+ state,
257
+ detail,
258
+ action,
170
259
  evidence: {
171
260
  pinCommit: identity.commit,
172
261
  marketplaceHead: head.commit,
173
262
  branch,
174
- ...(changedPaths === null ? {} : { changedPaths: changed, readLive, pinned }),
263
+ ...(comparison === null ? {} : {
264
+ changedPaths: changed,
265
+ readLive,
266
+ pinned,
267
+ reads: comparison.reads,
268
+ moved,
269
+ verdictMoved,
270
+ missing,
271
+ policy: { pin: pinPolicy, head: headPolicy },
272
+ }),
175
273
  },
176
274
  }
177
275
  }
178
276
 
179
277
  /**
180
278
  * @param {{ repoRoot: string, offline?: boolean, env?: object, npmPrefix?: () => string|null,
181
- * resolveHead?: typeof defaultBranchHead, latest?: typeof registryLatest }} options
279
+ * resolveHead?: typeof defaultBranchHead, latest?: typeof registryLatest, compare?: typeof comparePin }} options
182
280
  * `env` and `npmPrefix` are injectable for tests of the PATH check;
183
- * `resolveHead` and `latest` for tests of the two checks that read the
184
- * network, whose defaults are the tool's one HEAD resolver and its one
185
- * registry read.
281
+ * `resolveHead`, `latest` and `compare` for tests of the two checks that
282
+ * read the network, whose defaults are the tool's one HEAD resolver, its
283
+ * one registry read and the pin comparison above.
186
284
  */
187
- export async function doctor({ repoRoot, offline = false, onPhase, env = process.env, npmPrefix, resolveHead = defaultBranchHead, latest: latestVersion = registryLatest }) {
285
+ export async function doctor({ repoRoot, offline = false, onPhase, env = process.env, npmPrefix, resolveHead = defaultBranchHead, latest: latestVersion = registryLatest, compare = comparePin }) {
188
286
  // Optional: told what is being read while the network answers. Never
189
287
  // affects the result.
190
288
  const phase = onPhase || (() => {})
@@ -201,7 +299,9 @@ export async function doctor({ repoRoot, offline = false, onPhase, env = process
201
299
  // What is installed and what is published, one check: the two facts are
202
300
  // one question, "is this the current omakit", and were two lines before.
203
301
  // Offline, the first fact alone, as information. The evidence carries both
204
- // and where the second came from.
302
+ // and where the second came from. `upgrade` is the command when a newer
303
+ // omakit is published, for pin.freshness to name below.
304
+ let upgrade = null
205
305
  const versionCheck = (state, detail, action = null, latest = null, source = null) =>
206
306
  add("omakit.version", state, detail, action, { installed: self.version, latest, source })
207
307
  if (offline) {
@@ -214,11 +314,12 @@ export async function doctor({ repoRoot, offline = false, onPhase, env = process
214
314
  if (comparison === null) {
215
315
  versionCheck("unknown", `${self.version}; the installed or published version is invalid`)
216
316
  } else {
317
+ if (comparison > 0) upgrade = upgradeCommand(repoRoot, self.name)
217
318
  versionCheck(comparison > 0 ? "advice" : "ok",
218
319
  comparison === 0 ? `${self.version}, the newest published version`
219
320
  : comparison < 0 ? `${self.version}; ahead of the newest published version ${published.version}`
220
321
  : `${self.version}; ${published.version} is published`,
221
- comparison > 0 ? `run \`${upgradeCommand(repoRoot, self.name)}\`` : null,
322
+ comparison > 0 ? `run \`${upgrade}\`` : null,
222
323
  published.version, NPM_REGISTRY)
223
324
  }
224
325
  } else {
@@ -293,12 +394,12 @@ export async function doctor({ repoRoot, offline = false, onPhase, env = process
293
394
  phase("reading the marketplace's current default-branch HEAD")
294
395
  try {
295
396
  const head = await resolveHead(MARKETPLACE_PIN.repository)
296
- let changedPaths = []
397
+ let comparison = { changedPaths: [], reads: pinnedReadSet(dir).length, moved: [], missing: [], policyAtHead: null }
297
398
  if (head.commit !== identity.commit) {
298
- phase("comparing each path omakit reads between the pin and HEAD")
299
- changedPaths = await changedPinPaths({ pinDir: dir, headCommit: head.commit, pinCommit: identity.commit })
399
+ phase("comparing each file omakit reads between the pin and HEAD")
400
+ comparison = await compare({ pinDir: dir, headCommit: head.commit, pinCommit: identity.commit })
300
401
  }
301
- checks.push(pinFreshness(identity, head, changedPaths, { issues: self.repository }))
402
+ checks.push(pinFreshness(identity, head, comparison, { upgrade }))
302
403
  } catch (error) {
303
404
  add("pin.freshness", "unknown", `could not read the marketplace's HEAD (${error.code || "error"})`,
304
405
  error.code === "network-unavailable" ? "Connect to the network, or pass --offline to skip the two checks that need it." : null,
@@ -4,14 +4,15 @@
4
4
  //
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
- // seven files out of it. A blob-filtered, sparsely checked out fetch of just
8
- // those paths is 15 MB and takes 2 seconds instead of 17. PIN_PATHS below is the
9
- // whole list, and tests/unit/pin.test.mjs fails if any module starts reading a
10
- // path outside it, because on a partial clone such a read would quietly reach
11
- // for the network instead of failing.
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
10
+ // tests/unit/pin.test.mjs fails if any module starts reading a path outside
11
+ // it, because on a partial clone such a read would quietly reach for the
12
+ // network instead of failing.
12
13
  import { execFileSync, spawnSync } from "node:child_process"
13
14
  import { existsSync, readdirSync, readFileSync, mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs"
14
- import { dirname, join, resolve } from "node:path"
15
+ import { dirname, join, posix, resolve } from "node:path"
15
16
  import { omakitCacheDir } from "./paths.mjs"
16
17
 
17
18
  /** Raised for every way the pin can be missing or wrong; the code is what the CLI keys its remedy on. */
@@ -52,6 +53,147 @@ export const PIN_PATHS = Object.freeze([
52
53
  "/.github/ISSUE_TEMPLATE/",
53
54
  ])
54
55
 
56
+ /** The policy module, read at the pin for its two constants (readPinIdentity) and at HEAD as text by doctor for the same two. */
57
+ export const POLICY_MODULE = "scripts/security-baseline-policy.mjs"
58
+
59
+ /**
60
+ * The files omakit opens under /scripts/, and how. PIN_PATHS fetches the
61
+ * directory whole because these import each other; this is what is opened
62
+ * out of it, so `omakit doctor` can tell a change to one of the 34 files
63
+ * there at the pin that omakit reads from a change to one it does not.
64
+ * Measured on 2026-09-21 (docs/MEASUREMENTS.md M7): of the three commits
65
+ * that touched scripts/ since the first pin 38060f89, one touched only
66
+ * `repository-identity.mjs`, a file omakit neither opens nor imports
67
+ * through anything it opens, and doctor called the pin behind for it.
68
+ *
69
+ * An `imported` file is executed, and what it imports is read with it:
70
+ * pinnedReadSet() follows the static imports. A file read as text is read
71
+ * alone, since a constant taken out of its text does not change when its
72
+ * imports do.
73
+ *
74
+ * submission.mjs form.mjs, watch.mjs
75
+ * plugin-verification-request.mjs form.mjs, watch.mjs
76
+ * security-baseline-scanner.mjs run-baseline.mjs
77
+ * security-baseline-policy.mjs preflight.mjs, watch.mjs, review-cost.mjs;
78
+ * as text here, for readPinIdentity
79
+ * security-baseline-report.mjs preflight.mjs
80
+ * security-baseline-record.mjs watch.mjs
81
+ * submission-feedback.mjs watch.mjs, and as text for its codes
82
+ * build-catalog.mjs registry.mjs, text: it imports sharp
83
+ * approve-submission.mjs watch.mjs, text: one label
84
+ * approve-plugin-update.mjs review-cost.mjs, text: one label
85
+ *
86
+ * 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.
88
+ */
89
+ export const PIN_READS = Object.freeze([
90
+ Object.freeze({ path: "scripts/submission.mjs", imported: true }),
91
+ Object.freeze({ path: "scripts/plugin-verification-request.mjs", imported: true }),
92
+ Object.freeze({ path: "scripts/security-baseline-scanner.mjs", imported: true }),
93
+ Object.freeze({ path: POLICY_MODULE, imported: true }),
94
+ Object.freeze({ path: "scripts/security-baseline-report.mjs", imported: true }),
95
+ Object.freeze({ path: "scripts/security-baseline-record.mjs", imported: true }),
96
+ 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 }),
100
+ ])
101
+
102
+ /**
103
+ * The read files omakit takes only wording or a label out of, and decides
104
+ * nothing by: the baseline's rendered details, the failure table a refusal's
105
+ * reason is mapped through for display, and one label name each from the
106
+ * two approval scripts. A change to one of these changes what omakit
107
+ * prints, never whether it passes or refuses. Every other file in
108
+ * pinnedReadSet() is executed or read for a rule, a limit, a constant or a
109
+ * parser, so a change there can change a verdict, and `omakit doctor`
110
+ * grades it advice. The list is a whitelist on purpose: a file that is not
111
+ * here is treated as verdict-bearing until someone proves otherwise here.
112
+ */
113
+ export const WORDING_READS = Object.freeze([
114
+ "scripts/security-baseline-report.mjs",
115
+ "scripts/submission-feedback.mjs",
116
+ "scripts/approve-submission.mjs",
117
+ "scripts/approve-plugin-update.mjs",
118
+ ])
119
+
120
+ /** A static import or re-export of a relative module: `import x from "./y.mjs"`, `export * from "./y.mjs"`, across lines. */
121
+ export const RELATIVE_IMPORT = /\bfrom\s+["'](\.\.?\/[^"']+)["']/g
122
+
123
+ /**
124
+ * The import forms RELATIVE_IMPORT does not follow. (A relative `from`
125
+ * specifier of any kind is followed, a JSON import included, so that needs
126
+ * no guard; Node's ESM loader requires an extension.) If one of these ever
127
+ * appears in an executed file of the pinned read set, pinnedReadSet() could
128
+ * 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).
132
+ */
133
+ export const UNFOLLOWED_IMPORTS = Object.freeze([
134
+ Object.freeze({ form: "dynamic import()", pattern: /\bimport\s*\(/ }),
135
+ Object.freeze({ form: "side-effect import", pattern: /^\s*import\s+["']/ }),
136
+ Object.freeze({ form: "require()", pattern: /\brequire\s*\(/ }),
137
+ Object.freeze({ form: "import.meta.resolve()", pattern: /\bimport\.meta\.resolve\s*\(/ }),
138
+ ])
139
+
140
+ /**
141
+ * Every line in the read set's executed files that carries an import form
142
+ * the closure does not follow: `{ path, line, form, text }`, empty when the
143
+ * closure is complete. Files read as text are not scanned: nothing they
144
+ * import is loaded. Read as text, like the closure itself.
145
+ */
146
+ export function unfollowedImports(pinDir, reads = pinnedReadSet(pinDir)) {
147
+ const found = []
148
+ for (const { path } of reads.filter((read) => read.imported)) {
149
+ const lines = readFileSync(join(pinDir, path), "utf8").split("\n")
150
+ lines.forEach((text, index) => {
151
+ for (const { form, pattern } of UNFOLLOWED_IMPORTS) {
152
+ if (pattern.test(text)) found.push({ path, line: index + 1, form, text: text.trim() })
153
+ }
154
+ })
155
+ }
156
+ return found
157
+ }
158
+
159
+ /**
160
+ * Every file omakit reads under /scripts/ at the checkout in `pinDir`:
161
+ * PIN_READS, plus, for each imported one, what it imports in turn, found
162
+ * by regex over `from "./x.mjs"` in the file's text and never by loading
163
+ * it. Paths relative to the checkout, sorted, each once, with `via` naming
164
+ * the file that imports it or null for one omakit opens itself. A
165
+ * specifier that leaves scripts/ (the marketplace's site/ assets) or names
166
+ * 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.
169
+ *
170
+ * @returns {{ path: string, imported: boolean, via: string|null }[]}
171
+ */
172
+ export function pinnedReadSet(pinDir, reads = PIN_READS) {
173
+ const set = new Map()
174
+ const queue = []
175
+ for (const read of reads) {
176
+ set.set(read.path, { path: read.path, imported: read.imported, via: null })
177
+ if (read.imported) queue.push(read.path)
178
+ }
179
+ while (queue.length) {
180
+ const from = queue.shift()
181
+ let text
182
+ try {
183
+ text = readFileSync(join(pinDir, from), "utf8")
184
+ } catch (error) {
185
+ throw new PinError(`cannot read ${from} from the pinned checkout at ${pinDir}: ${error.message}`)
186
+ }
187
+ for (const match of text.matchAll(RELATIVE_IMPORT)) {
188
+ const path = posix.normalize(posix.join(posix.dirname(from), match[1]))
189
+ if (!path.startsWith("scripts/") || set.has(path)) continue
190
+ set.set(path, { path, imported: true, via: from })
191
+ queue.push(path)
192
+ }
193
+ }
194
+ return [...set.values()].sort((a, b) => a.path.localeCompare(b.path))
195
+ }
196
+
55
197
  /**
56
198
  * The user-writable pin location: `$XDG_CACHE_HOME/omakit/marketplace`, or
57
199
  * `~/.cache/omakit/marketplace`. omakit reads no variable of its own; a test or
@@ -86,14 +228,27 @@ function git(dir, args, options = {}) {
86
228
  return execFileSync("git", ["-C", dir, ...args], { timeout: 300_000, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], ...options })
87
229
  }
88
230
 
231
+ /**
232
+ * The two policy constants out of the policy module's text, the way the
233
+ * pin's identity has always read them: `securityBaselineVersion` and
234
+ * `securityBaselineEnforcementMode`, or "unknown" where the text has no
235
+ * such line. Text in, two strings out; the module is not loaded, so the
236
+ * same read serves doctor for the module at HEAD.
237
+ */
238
+ export function policyConstants(text) {
239
+ const source = String(text || "")
240
+ return {
241
+ baselineVersion: source.match(/securityBaselineVersion\s*=\s*"?([^";\s]+)"?/)?.[1] || "unknown",
242
+ enforcementMode: source.match(/securityBaselineEnforcementMode\s*=\s*"([^"]+)"/)?.[1] || "unknown",
243
+ }
244
+ }
245
+
89
246
  /** Identity of the checkout at `dir`: commit plus the policy constants read from the pinned source. */
90
247
  function readPinIdentity(dir) {
91
248
  const commit = git(dir, ["rev-parse", "HEAD"]).trim()
92
- const policy = git(dir, ["show", `${commit}:scripts/security-baseline-policy.mjs`])
93
- const version = policy.match(/securityBaselineVersion\s*=\s*"?([^";\s]+)"?/)?.[1] || "unknown"
94
- const mode = policy.match(/securityBaselineEnforcementMode\s*=\s*"([^"]+)"/)?.[1] || "unknown"
249
+ const { baselineVersion, enforcementMode } = policyConstants(git(dir, ["show", `${commit}:${POLICY_MODULE}`]))
95
250
  const dirty = git(dir, ["status", "--porcelain"]).trim().length > 0
96
- return { commit, baselineVersion: version, enforcementMode: mode, dirty }
251
+ return { commit, baselineVersion, enforcementMode, dirty }
97
252
  }
98
253
 
99
254
  /**
@@ -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, requirePin } from "./pin.mjs"
29
+ import { MARKETPLACE_PIN, POLICY_MODULE, requirePin } from "./pin.mjs"
30
30
  import { defaultBranchHead, getJson } from "./github.mjs"
31
31
  import { omakitCacheDir } from "./paths.mjs"
32
32
 
@@ -34,9 +34,17 @@ export const CATALOG_PATH = "site/catalog.json"
34
34
  export const REGISTRY_PATH = "registry.json"
35
35
  export const CATALOG_BUILDER_PATH = "scripts/build-catalog.mjs"
36
36
 
37
- /** The only two marketplace files ever read from HEAD. Everything else comes from the pin. */
37
+ /** The only two marketplace files whose content omakit uses from HEAD. Everything else comes from the pin. */
38
38
  export const LIVE_PATHS = Object.freeze([REGISTRY_PATH, CATALOG_PATH])
39
39
 
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.
45
+ */
46
+ export const HEAD_TEXT_PATHS = Object.freeze([POLICY_MODULE])
47
+
40
48
  export class RegistryError extends Error {
41
49
  constructor(code, message) {
42
50
  super(message)
@@ -140,7 +148,9 @@ export function sameRepository(a, b) {
140
148
  * one explicit 40-character commit on the marketplace's raw file host. Never
141
149
  * a branch name, so the two files always come from the same commit and the
142
150
  * commit named in the output is the one they came from; never a path outside
143
- * LIVE_PATHS, so nothing executable can arrive this way.
151
+ * 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.)
144
154
  */
145
155
  export function liveFileUrl(commit, path) {
146
156
  if (!/^[0-9a-f]{40}$/.test(String(commit))) {
@@ -149,6 +159,27 @@ export function liveFileUrl(commit, path) {
149
159
  if (!LIVE_PATHS.includes(path)) {
150
160
  throw new RegistryError("usage", `${path} is never read from HEAD; only ${LIVE_PATHS.join(" and ")} are`)
151
161
  }
162
+ return rawFileUrl(commit, path)
163
+ }
164
+
165
+ /**
166
+ * The same URL shape for the one file doctor reads from HEAD as text:
167
+ * 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.
170
+ */
171
+ export function headTextUrl(commit, path) {
172
+ if (!/^[0-9a-f]{40}$/.test(String(commit))) {
173
+ throw new RegistryError("usage", `a marketplace file is read at a 40-character commit, not "${commit}"`)
174
+ }
175
+ 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"}`)
177
+ }
178
+ return rawFileUrl(commit, path)
179
+ }
180
+
181
+ /** The raw file host at one commit; the two builders above are its only callers, and each holds its own path list. */
182
+ function rawFileUrl(commit, path) {
152
183
  const raw = MARKETPLACE_PIN.repository.replace(/^https:\/\/github\.com\//, "https://raw.githubusercontent.com/")
153
184
  return `${raw}/${commit}/${path}`
154
185
  }