omakit 0.1.7 → 0.1.8
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/README.md +8 -4
- package/package.json +1 -1
- package/skills/omarchy-plugin-submit/SKILL.md +3 -1
- package/tests/parity/run.mjs +145 -113
- package/tools/marketplace/README.md +16 -2
- package/tools/marketplace/cli.mjs +33 -18
- package/tools/marketplace/doctor.mjs +68 -25
- package/tools/marketplace/report.mjs +12 -6
- package/tools/marketplace/style.mjs +13 -3
- package/tools/marketplace/submit.mjs +17 -5
- package/tools/marketplace/usage.mjs +5 -4
- package/tools/marketplace/watch.mjs +21 -11
package/README.md
CHANGED
|
@@ -231,16 +231,20 @@ the cache. Bumping it changes where the submission contract and the baseline
|
|
|
231
231
|
policy are read from, and the procedure in
|
|
232
232
|
[docs/UPSTREAM_CONTRACT.md](docs/UPSTREAM_CONTRACT.md) ends in re-proving
|
|
233
233
|
transport parity and committing the evidence. `omakit doctor` tells you when the
|
|
234
|
-
pin is behind
|
|
235
|
-
|
|
236
|
-
|
|
234
|
+
pin is behind in something omakit reads from it, names which paths changed,
|
|
235
|
+
and then leaves it alone: `registry.json` and `site/catalog.json` moving is
|
|
236
|
+
fine, because those are read live from HEAD (about 140 commits a day touch
|
|
237
|
+
only `registry.json`, so "behind" alone would be true of every run); the
|
|
238
|
+
marketplace's code or forms moving is a note, and what you can do about it
|
|
239
|
+
is run `omakit upgrade`, since a newer omakit may already carry the new pin,
|
|
240
|
+
and otherwise open an issue naming the paths. That the
|
|
237
241
|
pin can go stale unnoticed is the same defect class `omakit watch` reports, so it
|
|
238
242
|
would be poor form to hide it here.
|
|
239
243
|
|
|
240
244
|
## Evidence, not claims
|
|
241
245
|
|
|
242
246
|
```bash
|
|
243
|
-
npm test #
|
|
247
|
+
npm test # node --test, no dependencies; green from `git archive` too
|
|
244
248
|
```
|
|
245
249
|
|
|
246
250
|
| Claim | Proof |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "omakit",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
4
4
|
"description": "The safe place to find out: everything knowable about an Omarchy Quattro plugin submission before you post it, on your own machine. Agent-first, read-only, posts nothing, zero dependencies.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Maarten Tolhuijs",
|
|
@@ -57,7 +57,9 @@ omakit verify <path-to-the-plugin-repo> --json
|
|
|
57
57
|
```
|
|
58
58
|
|
|
59
59
|
`verify` without `--json` prints a report for a person; `--json` is the
|
|
60
|
-
document, unchanged between releases.
|
|
60
|
+
document, unchanged between releases. It needs no network once the pin
|
|
61
|
+
exists; a reviewer-mode target (`<https url>@<40-char sha>`) is fetched once,
|
|
62
|
+
read-only, into omakit's own cache.
|
|
61
63
|
|
|
62
64
|
Useful flags: `--notes` for the Maintainer notes field, `--suggest-tag` for the
|
|
63
65
|
optional suggestion, `--name` when the manifest has no name, `--json` for a
|
package/tests/parity/run.mjs
CHANGED
|
@@ -14,6 +14,7 @@ import { createHash } from "node:crypto"
|
|
|
14
14
|
import { execFileSync } from "node:child_process"
|
|
15
15
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
16
16
|
import { join, resolve } from "node:path"
|
|
17
|
+
import { pathToFileURL } from "node:url"
|
|
17
18
|
import { runBaseline } from "../../tools/marketplace/run-baseline.mjs"
|
|
18
19
|
import { requirePin } from "../../tools/marketplace/pin.mjs"
|
|
19
20
|
import { parityCorpus, strataSizes } from "./corpus.mjs"
|
|
@@ -22,14 +23,7 @@ import { token } from "../../tools/marketplace/github.mjs"
|
|
|
22
23
|
import { omakitCacheDir } from "../../tools/marketplace/paths.mjs"
|
|
23
24
|
import { parityOutput } from "../../tools/marketplace/parity-output.mjs"
|
|
24
25
|
|
|
25
|
-
|
|
26
|
-
const pinDir = requirePin(repoRoot).dir
|
|
27
|
-
const cacheDir = omakitCacheDir("parity")
|
|
28
|
-
const output = parityOutput({ repoRoot, out: process.env.PARITY_OUT || null })
|
|
29
|
-
const count = Number(process.env.PARITY_COUNT || 30)
|
|
30
|
-
const offset = Number(process.env.PARITY_OFFSET || 0)
|
|
31
|
-
|
|
32
|
-
function shallowClone(repoUrl, commit) {
|
|
26
|
+
function shallowClone(cacheDir, repoUrl, commit) {
|
|
33
27
|
const dir = join(cacheDir, subjectSlug(repoUrl))
|
|
34
28
|
mkdirSync(dir, { recursive: true })
|
|
35
29
|
const run = (args) => execFileSync("git", ["-C", dir, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] })
|
|
@@ -71,124 +65,162 @@ function pinCommit(dir) {
|
|
|
71
65
|
}
|
|
72
66
|
}
|
|
73
67
|
|
|
74
|
-
|
|
75
|
-
const rows = []
|
|
76
|
-
const ruleTotals = {}
|
|
77
|
-
const capabilityTotals = {}
|
|
78
|
-
let mismatches = 0
|
|
79
|
-
let failures = 0
|
|
80
|
-
|
|
81
|
-
for (const target of corpus) {
|
|
82
|
-
const row = {
|
|
83
|
-
repo: target.repo,
|
|
84
|
-
commit: target.commit,
|
|
85
|
-
type: target.type,
|
|
86
|
-
registryOutcome: target.registryOutcome,
|
|
87
|
-
registryOutcomeAtSameCommit: target.registryOutcomeCommit === target.commit,
|
|
88
|
-
}
|
|
68
|
+
function generatorIdentity(repoRoot) {
|
|
89
69
|
try {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
70
|
+
return { omakitCommit: execFileSync("git", ["-C", repoRoot, "rev-parse", "HEAD"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() }
|
|
71
|
+
} catch {
|
|
72
|
+
const pkg = JSON.parse(readFileSync(join(repoRoot, "package.json"), "utf8"))
|
|
73
|
+
return { omakitPackage: `${pkg.name}@${pkg.version}` }
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The parity run. `cli.mjs` calls this with its own root and the flags it
|
|
79
|
+
* parsed; the adapter at the bottom of this file calls it from the command
|
|
80
|
+
* line. Everything the run needs arrives as an argument: omakit reads no
|
|
81
|
+
* environment variable of its own, and this handoff used to be four
|
|
82
|
+
* PARITY_* variables and an OMAKIT_ROOT, which was the one OMAKIT_* name in
|
|
83
|
+
* the tree and needed an exemption in the test that forbids them.
|
|
84
|
+
*
|
|
85
|
+
* @param {{ repoRoot: string, count?: number, offset?: number, out?: string|null, log?: (line: string) => void }} options
|
|
86
|
+
* `out` is an explicit evidence file; without it the evidence lands in
|
|
87
|
+
* `docs/evidence/parity/` under a dated name that never overwrites.
|
|
88
|
+
* @returns {Promise<{ summary: object, outFile: string, ok: boolean }>}
|
|
89
|
+
*/
|
|
90
|
+
export async function runParity({ repoRoot, count = 30, offset = 0, out = null, log = console.log }) {
|
|
91
|
+
const root = resolve(repoRoot)
|
|
92
|
+
const pinDir = requirePin(root).dir
|
|
93
|
+
const cacheDir = omakitCacheDir("parity")
|
|
94
|
+
// Resolved before any fetch, so a packaged read-only install refuses here.
|
|
95
|
+
const output = parityOutput({ repoRoot: root, out })
|
|
96
|
+
const corpus = parityCorpus(pinDir, count, offset)
|
|
97
|
+
const rows = []
|
|
98
|
+
const ruleTotals = {}
|
|
99
|
+
const capabilityTotals = {}
|
|
100
|
+
let mismatches = 0
|
|
101
|
+
let failures = 0
|
|
102
|
+
|
|
103
|
+
for (const target of corpus) {
|
|
104
|
+
const row = {
|
|
105
|
+
repo: target.repo,
|
|
106
|
+
commit: target.commit,
|
|
107
|
+
type: target.type,
|
|
108
|
+
registryOutcome: target.registryOutcome,
|
|
109
|
+
registryOutcomeAtSameCommit: target.registryOutcomeCommit === target.commit,
|
|
109
110
|
}
|
|
110
111
|
try {
|
|
111
|
-
const
|
|
112
|
-
|
|
112
|
+
const dir = shallowClone(cacheDir, target.repo, target.commit)
|
|
113
|
+
const local = await runBaseline({
|
|
114
|
+
repoRoot: root,
|
|
113
115
|
repoUrl: target.repo,
|
|
114
116
|
commitSha: target.commit,
|
|
115
|
-
transport: "
|
|
116
|
-
|
|
117
|
+
transport: "local",
|
|
118
|
+
repoDir: dir,
|
|
117
119
|
})
|
|
118
|
-
row.
|
|
119
|
-
row.
|
|
120
|
-
row.
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
120
|
+
row.localOutcome = local.result.outcome
|
|
121
|
+
row.requests = local.adapter.requests
|
|
122
|
+
row.localDigest = digest(local.result)
|
|
123
|
+
// Rule and capability ids are counted in the summary, unattributed; they are
|
|
124
|
+
// deliberately not recorded against this repository.
|
|
125
|
+
for (const finding of local.result.findings || []) {
|
|
126
|
+
const id = finding.ruleId || finding.id
|
|
127
|
+
if (id) ruleTotals[id] = (ruleTotals[id] || 0) + 1
|
|
128
|
+
}
|
|
129
|
+
for (const capability of local.result.capabilities || []) {
|
|
130
|
+
if (capability.id) capabilityTotals[capability.id] = (capabilityTotals[capability.id] || 0) + 1
|
|
131
|
+
}
|
|
132
|
+
try {
|
|
133
|
+
const github = await runBaseline({
|
|
134
|
+
repoRoot: root,
|
|
135
|
+
repoUrl: target.repo,
|
|
136
|
+
commitSha: target.commit,
|
|
137
|
+
transport: "github",
|
|
138
|
+
token: token() ?? undefined,
|
|
139
|
+
})
|
|
140
|
+
row.githubOutcome = github.result.outcome
|
|
141
|
+
row.githubDigest = digest(github.result)
|
|
142
|
+
row.identical = row.localDigest === row.githubDigest
|
|
143
|
+
if (!row.identical) {
|
|
144
|
+
mismatches += 1
|
|
145
|
+
// A mismatch is the one case worth describing, and it is described as a
|
|
146
|
+
// difference between two transports of the same code, not as a finding
|
|
147
|
+
// about the plugin: which keys differ, never their contents.
|
|
148
|
+
const localPayload = JSON.parse(comparable(local.result))
|
|
149
|
+
const githubPayload = JSON.parse(comparable(github.result))
|
|
150
|
+
row.differingKeys = Object.keys(localPayload).filter(
|
|
151
|
+
(key) => JSON.stringify(localPayload[key]) !== JSON.stringify(githubPayload[key]),
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
} catch (error) {
|
|
155
|
+
row.githubError = (error.code || "error") + ": " + error.message
|
|
156
|
+
failures += 1
|
|
131
157
|
}
|
|
132
158
|
} catch (error) {
|
|
133
|
-
row.
|
|
159
|
+
row.localError = (error.code || "error") + ": " + error.message
|
|
134
160
|
failures += 1
|
|
135
161
|
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
162
|
+
rows.push(row)
|
|
163
|
+
log(
|
|
164
|
+
[
|
|
165
|
+
row.identical === true ? "ok " : row.identical === false ? "DIFF" : "?? ",
|
|
166
|
+
target.repo.replace("https://github.com/", "").padEnd(44),
|
|
167
|
+
"local=" + (row.localOutcome || row.localError || "-"),
|
|
168
|
+
"github=" + (row.githubOutcome || row.githubError || "-"),
|
|
169
|
+
"registry=" + (row.registryOutcome || "-"),
|
|
170
|
+
].join(" "),
|
|
171
|
+
)
|
|
139
172
|
}
|
|
140
|
-
rows.push(row)
|
|
141
|
-
console.log(
|
|
142
|
-
[
|
|
143
|
-
row.identical === true ? "ok " : row.identical === false ? "DIFF" : "?? ",
|
|
144
|
-
target.repo.replace("https://github.com/", "").padEnd(44),
|
|
145
|
-
"local=" + (row.localOutcome || row.localError || "-"),
|
|
146
|
-
"github=" + (row.githubOutcome || row.githubError || "-"),
|
|
147
|
-
"registry=" + (row.registryOutcome || "-"),
|
|
148
|
-
].join(" "),
|
|
149
|
-
)
|
|
150
|
-
}
|
|
151
173
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
174
|
+
const summary = {
|
|
175
|
+
generator: generatorIdentity(root),
|
|
176
|
+
pinnedMarketplaceCommit: pinCommit(pinDir),
|
|
177
|
+
corpusSize: corpus.length,
|
|
178
|
+
requestedCount: count,
|
|
179
|
+
offset,
|
|
180
|
+
strata: strataSizes(count),
|
|
181
|
+
outcomes: rows.reduce((acc, row) => {
|
|
182
|
+
const key = row.localOutcome || "error"
|
|
183
|
+
acc[key] = (acc[key] || 0) + 1
|
|
184
|
+
return acc
|
|
185
|
+
}, {}),
|
|
186
|
+
identical: rows.filter((row) => row.identical === true).length,
|
|
187
|
+
mismatches,
|
|
188
|
+
failures,
|
|
189
|
+
// Unattributed aggregates. No repository name is attached to any rule.
|
|
190
|
+
ruleTotals,
|
|
191
|
+
capabilityTotals,
|
|
192
|
+
publication: {
|
|
193
|
+
perRepositoryDetail: "omitted",
|
|
194
|
+
reason: "Findings about a specific third-party plugin are not published. Equality is evidenced by the sha256 digests below, which anyone can recompute from the repository, the commit and the pinned marketplace commit.",
|
|
195
|
+
digest: "sha256 of {outcome, disposition, blocksApproval, findings, capabilities}, first 32 hex characters",
|
|
196
|
+
},
|
|
197
|
+
rows,
|
|
158
198
|
}
|
|
199
|
+
const date = new Date().toISOString().slice(0, 10)
|
|
200
|
+
const outputIsFile = Boolean(out)
|
|
201
|
+
const outputDir = outputIsFile ? resolve(output, "..") : output
|
|
202
|
+
mkdirSync(outputDir, { recursive: true })
|
|
203
|
+
let outFile = outputIsFile ? output : join(outputDir, `${date}-local-vs-github.json`)
|
|
204
|
+
for (let n = 2; !outputIsFile && existsSync(outFile); n += 1) outFile = join(outputDir, `${date}-local-vs-github-${n}.json`)
|
|
205
|
+
writeFileSync(outFile, JSON.stringify(summary, null, 2) + "\n")
|
|
206
|
+
log("\nidentical " + summary.identical + "/" + corpus.length + ", mismatches " + mismatches + ", failures " + failures + " -> " + outFile)
|
|
207
|
+
return { summary, outFile, ok: mismatches === 0 && failures === 0 }
|
|
159
208
|
}
|
|
160
209
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
ruleTotals,
|
|
178
|
-
capabilityTotals,
|
|
179
|
-
publication: {
|
|
180
|
-
perRepositoryDetail: "omitted",
|
|
181
|
-
reason: "Findings about a specific third-party plugin are not published. Equality is evidenced by the sha256 digests below, which anyone can recompute from the repository, the commit and the pinned marketplace commit.",
|
|
182
|
-
digest: "sha256 of {outcome, disposition, blocksApproval, findings, capabilities}, first 32 hex characters",
|
|
183
|
-
},
|
|
184
|
-
rows,
|
|
210
|
+
// The command-line adapter: `node tests/parity/run.mjs [--count n] [--offset n]
|
|
211
|
+
// [--out file]` from the repository root. Thin on purpose; `omakit parity`
|
|
212
|
+
// is the same call with cli.mjs's own root.
|
|
213
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
|
|
214
|
+
const args = process.argv.slice(2)
|
|
215
|
+
const flag = (name) => {
|
|
216
|
+
const index = args.indexOf(name)
|
|
217
|
+
return index >= 0 ? args[index + 1] : undefined
|
|
218
|
+
}
|
|
219
|
+
const { ok } = await runParity({
|
|
220
|
+
repoRoot: process.cwd(),
|
|
221
|
+
count: Number(flag("--count") || 30),
|
|
222
|
+
offset: Number(flag("--offset") || 0),
|
|
223
|
+
out: flag("--out") || null,
|
|
224
|
+
})
|
|
225
|
+
process.exit(ok ? 0 : 1)
|
|
185
226
|
}
|
|
186
|
-
const date = new Date().toISOString().slice(0, 10)
|
|
187
|
-
const outputIsFile = process.env.PARITY_OUT_EXPLICIT === "1"
|
|
188
|
-
const outputDir = outputIsFile ? resolve(output, "..") : output
|
|
189
|
-
mkdirSync(outputDir, { recursive: true })
|
|
190
|
-
let outFile = outputIsFile ? output : join(outputDir, `${date}-local-vs-github.json`)
|
|
191
|
-
for (let n = 2; !outputIsFile && existsSync(outFile); n += 1) outFile = join(outputDir, `${date}-local-vs-github-${n}.json`)
|
|
192
|
-
writeFileSync(outFile, JSON.stringify(summary, null, 2) + "\n")
|
|
193
|
-
console.log("\nidentical " + summary.identical + "/" + corpus.length + ", mismatches " + mismatches + ", failures " + failures + " -> " + outFile)
|
|
194
|
-
process.exit(mismatches === 0 && failures === 0 ? 0 : 1)
|
|
@@ -19,11 +19,12 @@ local commit through the transport seam the marketplace tests itself
|
|
|
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
|
| `issue.mjs` | Renders the issue the way the form would, then has the marketplace's own parser judge it. |
|
|
22
|
-
| `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. |
|
|
22
|
+
| `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)". |
|
|
23
23
|
| `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. |
|
|
24
|
+
| `doctor.mjs` | What is installed, what is pinned, and what has moved. `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. |
|
|
24
25
|
| `watch.mjs` | The validation watch: validated commit versus current default-branch HEAD, and the one action that refreshes it. |
|
|
25
26
|
| `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. |
|
|
26
|
-
| `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. `docs/TUI.md` explains it. |
|
|
27
|
+
| `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. |
|
|
27
28
|
| `report.mjs` | Text rendering of submit, watch, doctor and verify for the agent that runs this tool, and the person reading over its shoulder. |
|
|
28
29
|
| `path-hint.mjs` | Is `omakit` reachable as a bare command, and if not, the one line that makes it so for the install that is here: a symlink for a clone, the npm prefix's `bin` on PATH for a package, said for the shell in `$SHELL`. `setup` and `doctor` print it; nothing writes an rc file. |
|
|
29
30
|
| `usage.mjs` | The help text, as data. |
|
|
@@ -60,6 +61,19 @@ the registry recorded so the corpus always contains repositories that are not
|
|
|
60
61
|
side rather than the findings themselves. The GitHub side uses whatever
|
|
61
62
|
credential `github.mjs` resolves (a `gh` login, and only that), read-only.
|
|
62
63
|
|
|
64
|
+
Two rules about the output, stated as rules because each is an exception to
|
|
65
|
+
a wider one. Everything omakit writes itself stays within 80 columns; text
|
|
66
|
+
that will be posted verbatim, the marketplace's own baseline report and the
|
|
67
|
+
issue body rendered from the pinned form, is never wrapped and may exceed 80,
|
|
68
|
+
because a wrapped body would not be the body. And stdout is the whole result;
|
|
69
|
+
stderr carries interactive decoration, the progress line from `progress.mjs`
|
|
70
|
+
and the chooser from `ask.mjs`, only when stderr is a TTY, so a piped stderr
|
|
71
|
+
is empty on success, and failures go to stderr always. `tests/unit/cli.test.mjs`
|
|
72
|
+
holds both: the width test names the two verbatim regions by their heading
|
|
73
|
+
and holds every other line to 80, the three-way test asserts the empty pipe,
|
|
74
|
+
and a pseudo-terminal test asserts that a terminal's stderr gets the progress
|
|
75
|
+
sequences and nothing else.
|
|
76
|
+
|
|
63
77
|
`parity`, `watch`, `doctor` and `submit` reach the network, with Node's
|
|
64
78
|
built-in `fetch`, which does not read proxy environment variables by default.
|
|
65
79
|
Behind a proxy, run them with `NODE_USE_ENV_PROXY=1`. `submit` reads two things
|
|
@@ -30,7 +30,6 @@ import { banner, bannerEnabled } from "./banner.mjs"
|
|
|
30
30
|
import { COMMANDS, renderSummary, renderUsage, TAGLINE } from "./usage.mjs"
|
|
31
31
|
import { action, colourEnabled, GUTTER, labelled, mark, styler, wrap } from "./style.mjs"
|
|
32
32
|
import { omakitCacheDir } from "./paths.mjs"
|
|
33
|
-
import { parityOutput } from "./parity-output.mjs"
|
|
34
33
|
|
|
35
34
|
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..")
|
|
36
35
|
|
|
@@ -84,6 +83,18 @@ function positionals(args) {
|
|
|
84
83
|
return args.filter((value, index) => !value.startsWith("--") && !valued.has(args[index - 1]))
|
|
85
84
|
}
|
|
86
85
|
|
|
86
|
+
/**
|
|
87
|
+
* The progress line for a command, or nothing under --json: a machine
|
|
88
|
+
* reading the document on stdout gets no decoration on stderr either.
|
|
89
|
+
* Measured on 0.1.6: submit, watch and doctor silenced it and verify did
|
|
90
|
+
* not, so `verify --json` at a terminal drew a progress line the others
|
|
91
|
+
* never drew.
|
|
92
|
+
*/
|
|
93
|
+
const SILENT = Object.freeze({ phase: () => {}, done: () => {} })
|
|
94
|
+
function spinnerFor(args) {
|
|
95
|
+
return args.includes("--json") ? SILENT : progress()
|
|
96
|
+
}
|
|
97
|
+
|
|
87
98
|
function emit(args, text) {
|
|
88
99
|
const out = option(args, "--out")
|
|
89
100
|
if (out) {
|
|
@@ -100,7 +111,7 @@ async function cmdSubmit(args) {
|
|
|
100
111
|
const target = positionals(args)[0]
|
|
101
112
|
if (!target) fail("usage", "submit needs a target: `omakit submit <target> --category <c> --tags <a,b>`", 2)
|
|
102
113
|
const json = args.includes("--json")
|
|
103
|
-
const spinner =
|
|
114
|
+
const spinner = spinnerFor(args)
|
|
104
115
|
// A missing --category or --tags on an unlisted plugin is asked for, once
|
|
105
116
|
// each, when a person is at a terminal on both ends and no machine is
|
|
106
117
|
// reading the result. Anything else, a pipe, an agent, --json, gets the
|
|
@@ -156,7 +167,7 @@ async function cmdSubmit(args) {
|
|
|
156
167
|
async function cmdWatch(args) {
|
|
157
168
|
const issueUrl = positionals(args)[0]
|
|
158
169
|
if (!issueUrl) fail("usage", "watch needs an issue: `omakit watch <issue-url>`", 2)
|
|
159
|
-
const spinner = args
|
|
170
|
+
const spinner = spinnerFor(args)
|
|
160
171
|
let result
|
|
161
172
|
try {
|
|
162
173
|
result = await validationWatch({ repoRoot: ROOT, issueUrl, onPhase: spinner.phase })
|
|
@@ -189,7 +200,7 @@ async function cmdUpgrade(args) {
|
|
|
189
200
|
}
|
|
190
201
|
|
|
191
202
|
async function cmdDoctor(args) {
|
|
192
|
-
const spinner = args
|
|
203
|
+
const spinner = spinnerFor(args)
|
|
193
204
|
const result = await doctor({ repoRoot: ROOT, offline: args.includes("--offline"), onPhase: spinner.phase })
|
|
194
205
|
spinner.done()
|
|
195
206
|
emit(args, args.includes("--json") ? `${JSON.stringify(result, null, 2)}\n` : renderDoctor(result))
|
|
@@ -206,7 +217,7 @@ async function cmdVerify(args) {
|
|
|
206
217
|
if (error instanceof SubjectError) fail(error.code, error.message, error.code === "usage" ? 2 : 1)
|
|
207
218
|
throw error
|
|
208
219
|
}
|
|
209
|
-
const spinner =
|
|
220
|
+
const spinner = spinnerFor(args)
|
|
210
221
|
let section
|
|
211
222
|
try {
|
|
212
223
|
spinner.phase("running the official security baseline over a local snapshot")
|
|
@@ -239,23 +250,27 @@ async function cmdVerify(args) {
|
|
|
239
250
|
}
|
|
240
251
|
|
|
241
252
|
async function cmdParity(args) {
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
253
|
+
// The runner takes everything as arguments. Before 0.1.8 this handed over
|
|
254
|
+
// four PARITY_* variables and an OMAKIT_ROOT through the process
|
|
255
|
+
// environment, and OMAKIT_ROOT was the one OMAKIT_* name in the tree.
|
|
256
|
+
let ok = false
|
|
246
257
|
try {
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
258
|
+
// Imported here, not at the top: the runner ships with the package, but
|
|
259
|
+
// no other command needs it, and a copy of bin/ and tools/ alone runs
|
|
260
|
+
// everything else.
|
|
261
|
+
const { runParity } = await import("../../tests/parity/run.mjs")
|
|
262
|
+
const count = option(args, "--count")
|
|
263
|
+
const offset = option(args, "--offset")
|
|
264
|
+
;({ ok } = await runParity({
|
|
265
|
+
repoRoot: ROOT,
|
|
266
|
+
count: count ? Number(count) : undefined,
|
|
267
|
+
offset: offset ? Number(offset) : undefined,
|
|
268
|
+
out: option(args, "--out") || null,
|
|
269
|
+
}))
|
|
256
270
|
} catch (error) {
|
|
257
271
|
failFrom(error)
|
|
258
272
|
}
|
|
273
|
+
process.exit(ok ? 0 : 1)
|
|
259
274
|
}
|
|
260
275
|
|
|
261
276
|
const [command, ...rest] = process.argv.slice(2)
|
|
@@ -13,33 +13,51 @@
|
|
|
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
|
|
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.
|
|
17
22
|
//
|
|
18
23
|
// That the pin goes stale unnoticed is, of course, exactly the defect class
|
|
19
24
|
// `omakit watch` exists to report. It would be poor form not to apply it here.
|
|
20
25
|
//
|
|
21
26
|
// And "behind" is not the same as "behind in something omakit reads". Measured
|
|
22
27
|
// on 2026-09-13 (docs/MEASUREMENTS.md M7): 4,201 of the marketplace's 4,293
|
|
23
|
-
// commits in 30 days touched only registry.json, which omakit
|
|
28
|
+
// commits in 30 days touched only registry.json, which omakit reads live,
|
|
24
29
|
// while nothing under scripts/ or .github/ISSUE_TEMPLATE/ changed since the
|
|
25
30
|
// pin. A doctor that only said "behind" would say it about every run. So it
|
|
26
31
|
// compares each path in PIN_PATHS between the pin and HEAD, by tree or blob
|
|
27
|
-
// id, and
|
|
32
|
+
// id, and splits the ones that moved by the same list registry.mjs reads
|
|
33
|
+
// live from: the two data files, which a moved HEAD cannot make stale, and
|
|
34
|
+
// everything else, which only a new pin can carry. Measured on 0.1.7: with
|
|
35
|
+
// only registry.json and site/catalog.json moved, doctor said `note` and
|
|
36
|
+
// pointed a user at docs/UPSTREAM_CONTRACT.md, though the pin was behind in
|
|
37
|
+
// nothing the tool uses.
|
|
28
38
|
|
|
29
39
|
import { execFileSync } from "node:child_process"
|
|
30
40
|
import { readFileSync } from "node:fs"
|
|
31
41
|
import { join } from "node:path"
|
|
32
42
|
import { MARKETPLACE_PIN, PIN_PATHS, marketplacePinDir, pinDiskUsage, pinIsSparse, requirePin } from "./pin.mjs"
|
|
43
|
+
import { LIVE_PATHS } from "./registry.mjs"
|
|
33
44
|
import { credential, defaultBranchHead, getJson, UNAUTHENTICATED_LIMIT, GitHubError } from "./github.mjs"
|
|
34
45
|
import { latestOnRegistry, upgradeCommand } from "./upgrade.mjs"
|
|
35
46
|
import { pathHint } from "./path-hint.mjs"
|
|
36
47
|
|
|
48
|
+
/** "git+https://github.com/owner/name.git" in package.json -> "https://github.com/owner/name", or null. */
|
|
49
|
+
function repositoryPage(repository) {
|
|
50
|
+
const url = typeof repository === "string" ? repository : repository?.url
|
|
51
|
+
const match = String(url || "").match(/github\.com[/:]([^/]+)\/([^/]+?)(?:\.git)?\/?$/)
|
|
52
|
+
return match ? `https://github.com/${match[1]}/${match[2]}` : null
|
|
53
|
+
}
|
|
54
|
+
|
|
37
55
|
function tool(repoRoot) {
|
|
38
56
|
try {
|
|
39
57
|
const pkg = JSON.parse(readFileSync(join(repoRoot, "package.json"), "utf8"))
|
|
40
|
-
return { name: pkg.name, version: pkg.version, engines: pkg.engines?.node || null }
|
|
58
|
+
return { name: pkg.name, version: pkg.version, engines: pkg.engines?.node || null, repository: repositoryPage(pkg.repository) }
|
|
41
59
|
} catch {
|
|
42
|
-
return { name: "omakit", version: "unknown", engines: null }
|
|
60
|
+
return { name: "omakit", version: "unknown", engines: null, repository: null }
|
|
43
61
|
}
|
|
44
62
|
}
|
|
45
63
|
|
|
@@ -102,40 +120,65 @@ export async function changedPinPaths({ pinDir, headCommit, pinCommit = MARKETPL
|
|
|
102
120
|
return changed
|
|
103
121
|
}
|
|
104
122
|
|
|
123
|
+
/** A PIN_PATHS pattern as a path: "/site/catalog.json" -> "site/catalog.json". */
|
|
124
|
+
const asPath = (pattern) => pattern.replace(/^\/|\/$/g, "")
|
|
125
|
+
|
|
126
|
+
/** "a", "a and b", "a, b and c". */
|
|
127
|
+
function list(items) {
|
|
128
|
+
return items.length < 3 ? items.join(" and ") : `${items.slice(0, -1).join(", ")} and ${items.at(-1)}`
|
|
129
|
+
}
|
|
130
|
+
|
|
105
131
|
/**
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
132
|
+
* The pin against the marketplace's HEAD, for a user. `changedPaths` is the
|
|
133
|
+
* answer from changedPinPaths(), split by LIVE_PATHS into `readLive`, the
|
|
134
|
+
* data files a moved HEAD cannot make stale because registry.mjs reads them
|
|
135
|
+
* from HEAD, and `pinned`, everything only a new pin can carry. Only the
|
|
136
|
+
* second set is worth a note; the action for it is a user's, not the
|
|
137
|
+
* maintainer's. Null when the comparison was not made, and then the detail
|
|
138
|
+
* says only that HEAD moved. Full commits in the evidence; short ones in
|
|
139
|
+
* the detail. `issues` is where a user reports a pinned path that moved,
|
|
140
|
+
* read from package.json by the caller.
|
|
109
141
|
*/
|
|
110
|
-
export function pinFreshness(identity, head, changedPaths = null) {
|
|
142
|
+
export function pinFreshness(identity, head, changedPaths = null, { issues = null } = {}) {
|
|
111
143
|
const current = head.commit === identity.commit
|
|
112
144
|
const branch = head.branch || "default"
|
|
145
|
+
const changed = current ? [] : changedPaths || []
|
|
146
|
+
const readLive = changed.filter((pattern) => LIVE_PATHS.includes(asPath(pattern)))
|
|
147
|
+
const pinned = changed.filter((pattern) => !LIVE_PATHS.includes(asPath(pattern)))
|
|
148
|
+
const where = `pin ${identity.commit.slice(0, 7)}; marketplace ${branch} at ${head.commit.slice(0, 7)}`
|
|
113
149
|
const moved = changedPaths === null
|
|
114
|
-
? ""
|
|
115
|
-
:
|
|
116
|
-
? `; changed since the pin: ${
|
|
117
|
-
:
|
|
150
|
+
? "; the paths omakit reads were not compared"
|
|
151
|
+
: pinned.length
|
|
152
|
+
? `; changed since the pin: ${pinned.join(", ")}${readLive.length ? ` (${list(readLive.map(asPath))} moved too, and ${readLive.length === 1 ? "that is" : "those are"} read live)` : ""}`
|
|
153
|
+
: readLive.length
|
|
154
|
+
? `; only ${list(readLive.map(asPath))} moved, and ${readLive.length === 1 ? "that is" : "those are"} read live`
|
|
155
|
+
: "; nothing omakit reads moved"
|
|
156
|
+
const stale = !current && (changedPaths === null || pinned.length > 0)
|
|
118
157
|
return {
|
|
119
158
|
id: "pin.freshness",
|
|
120
|
-
state:
|
|
121
|
-
detail: current
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
159
|
+
state: stale ? "advice" : "ok",
|
|
160
|
+
detail: current ? `the pin is the marketplace's current ${branch}-branch HEAD` : `${where}${moved}`,
|
|
161
|
+
action: stale
|
|
162
|
+
? `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.`
|
|
163
|
+
: null,
|
|
125
164
|
evidence: {
|
|
126
165
|
pinCommit: identity.commit,
|
|
127
166
|
marketplaceHead: head.commit,
|
|
128
167
|
branch,
|
|
129
|
-
...(changedPaths === null ? {} : { changedPaths:
|
|
168
|
+
...(changedPaths === null ? {} : { changedPaths: changed, readLive, pinned }),
|
|
130
169
|
},
|
|
131
170
|
}
|
|
132
171
|
}
|
|
133
172
|
|
|
134
173
|
/**
|
|
135
|
-
* @param {{ repoRoot: string, offline?: boolean, env?: object, npmPrefix?: () => string|null
|
|
136
|
-
*
|
|
174
|
+
* @param {{ repoRoot: string, offline?: boolean, env?: object, npmPrefix?: () => string|null,
|
|
175
|
+
* resolveHead?: typeof defaultBranchHead, latest?: typeof latestOnRegistry }} options
|
|
176
|
+
* `env` and `npmPrefix` are injectable for tests of the PATH check;
|
|
177
|
+
* `resolveHead` and `latest` for tests of the two checks that read the
|
|
178
|
+
* network, whose defaults are the tool's one HEAD resolver and its one
|
|
179
|
+
* registry read.
|
|
137
180
|
*/
|
|
138
|
-
export async function doctor({ repoRoot, offline = false, onPhase, env = process.env, npmPrefix }) {
|
|
181
|
+
export async function doctor({ repoRoot, offline = false, onPhase, env = process.env, npmPrefix, resolveHead = defaultBranchHead, latest: latestVersion = latestOnRegistry }) {
|
|
139
182
|
// Optional: told what is being read while the network answers. Never
|
|
140
183
|
// affects the result.
|
|
141
184
|
const phase = onPhase || (() => {})
|
|
@@ -185,13 +228,13 @@ export async function doctor({ repoRoot, offline = false, onPhase, env = process
|
|
|
185
228
|
if (!offline && identity) {
|
|
186
229
|
phase("reading the marketplace's current default-branch HEAD")
|
|
187
230
|
try {
|
|
188
|
-
const head = await
|
|
231
|
+
const head = await resolveHead(MARKETPLACE_PIN.repository)
|
|
189
232
|
let changedPaths = []
|
|
190
233
|
if (head.commit !== identity.commit) {
|
|
191
234
|
phase("comparing each path omakit reads between the pin and HEAD")
|
|
192
235
|
changedPaths = await changedPinPaths({ pinDir: dir, headCommit: head.commit, pinCommit: identity.commit })
|
|
193
236
|
}
|
|
194
|
-
checks.push(pinFreshness(identity, head, changedPaths))
|
|
237
|
+
checks.push(pinFreshness(identity, head, changedPaths, { issues: self.repository }))
|
|
195
238
|
} catch (error) {
|
|
196
239
|
add("pin.freshness", "unknown", `could not read the marketplace's HEAD (${error.code || "error"})`,
|
|
197
240
|
error.code === "network-unavailable" ? "Connect to the network, or pass --offline to skip the two checks that need it." : null,
|
|
@@ -199,7 +242,7 @@ export async function doctor({ repoRoot, offline = false, onPhase, env = process
|
|
|
199
242
|
}
|
|
200
243
|
|
|
201
244
|
phase("asking the npm registry for the newest published version")
|
|
202
|
-
const latest = await
|
|
245
|
+
const latest = await latestVersion(self.name)
|
|
203
246
|
if (latest) {
|
|
204
247
|
const current = latest === self.version
|
|
205
248
|
add("omakit.latest", current ? "ok" : "advice",
|
|
@@ -23,10 +23,11 @@ import {
|
|
|
23
23
|
|
|
24
24
|
const body = " ".repeat(GUTTER)
|
|
25
25
|
|
|
26
|
-
/** The state a check renders in: an advisory failure is a note, not a FAIL,
|
|
26
|
+
/** The state a check renders in: an advisory failure is a note, not a FAIL, a check that waited on another is a question, and a check a flag skipped says so. */
|
|
27
27
|
function stateOf(check) {
|
|
28
28
|
if (check.verdict === "pass") return "pass"
|
|
29
29
|
if (check.verdict === "unknown") return "unknown"
|
|
30
|
+
if (check.verdict === "skipped") return "skipped"
|
|
30
31
|
return check.severity === "advisory" ? "advisory" : "fail"
|
|
31
32
|
}
|
|
32
33
|
|
|
@@ -101,10 +102,10 @@ export function renderSubmit(result, { colour = colourEnabled() } = {}) {
|
|
|
101
102
|
out.push(...field("marketplace", `${result.pin.commit}, baseline ${result.pin.baselineVersion}, ${result.pin.enforcementMode}`, c))
|
|
102
103
|
out.push("")
|
|
103
104
|
|
|
104
|
-
// Passing checks run together, and so
|
|
105
|
-
//
|
|
106
|
-
// collapsed where two blocks meet.
|
|
107
|
-
const quiet = (state) => state === "pass" || state === "unknown"
|
|
105
|
+
// Passing checks run together, and so do a check that waited on another
|
|
106
|
+
// and one a flag skipped: all are two quiet lines. Anything else gets a
|
|
107
|
+
// blank line on each side, collapsed where two blocks meet.
|
|
108
|
+
const quiet = (state) => state === "pass" || state === "unknown" || state === "skipped"
|
|
108
109
|
let previous = "pass"
|
|
109
110
|
for (const [index, check] of result.checks.entries()) {
|
|
110
111
|
const state = stateOf(check)
|
|
@@ -168,7 +169,12 @@ export function renderSubmit(result, { colour = colourEnabled() } = {}) {
|
|
|
168
169
|
const validation = result.validationCommit.defaultBranchHead
|
|
169
170
|
? `${result.validationCommit.local} is the ${result.validationCommit.branch || "default"}-branch HEAD, so it is the commit the marketplace will validate.`
|
|
170
171
|
: `${result.validationCommit.local} is the local commit; the marketplace validates the default-branch HEAD it resolves when the issue is opened.`
|
|
171
|
-
|
|
172
|
+
// A skipped check is said on the READY line itself, so the one line an
|
|
173
|
+
// agent quotes does not read as "everything passed" when one check never
|
|
174
|
+
// ran. The count is the whole `skipped` list; today only --offline skips.
|
|
175
|
+
const skipped = (result.skipped || []).length
|
|
176
|
+
const skippedNote = skipped ? ` ${skipped === 1 ? "1 check" : `${skipped} checks`} skipped (--offline).` : ""
|
|
177
|
+
out.push(...verdict("pass", "READY", `every blocking check passed.${skippedNote} ${validation}`, c))
|
|
172
178
|
out.push("")
|
|
173
179
|
out.push(...section("issue title", c))
|
|
174
180
|
out.push(c("heading", result.issue.title))
|
|
@@ -162,6 +162,7 @@ export const DENSITY = Object.freeze({
|
|
|
162
162
|
medium: "▒", // ▒ unknown: neither here nor there
|
|
163
163
|
light: "░", // ░ information: present, weightless
|
|
164
164
|
floor: "▁", // ▁ settled: a pass, a rule, the track the scanner runs on
|
|
165
|
+
ceiling: "▔", // ▔ skipped: the floor's own ink, never landed, because a flag said not to look
|
|
165
166
|
})
|
|
166
167
|
|
|
167
168
|
/** The glyph that starts the one line that fixes things. */
|
|
@@ -170,15 +171,23 @@ export const ARROW = "→"
|
|
|
170
171
|
// --- the status vocabulary ---------------------------------------------------
|
|
171
172
|
|
|
172
173
|
/**
|
|
173
|
-
*
|
|
174
|
+
* Six states, and only six. Each has one glyph, one word and one tint, and
|
|
174
175
|
* every command prints them through `mark()` below. `pass` and `fail` are
|
|
175
176
|
* verdicts; `advisory` is a failure that does not block; `info` is a fact with
|
|
176
|
-
* no verdict; `unknown` is a check that could not be made
|
|
177
|
+
* no verdict; `unknown` is a check that could not be made; `skipped` is a
|
|
178
|
+
* check that was not made because a flag said not to (`--offline`).
|
|
177
179
|
*
|
|
178
180
|
* The glyph is the density ramp read as severity: the more ink, the more it
|
|
179
181
|
* matters. The word is the same thing in letters, and its case carries it too:
|
|
180
182
|
* FAIL is the only upper-case mark, so it is the one the eye lands on in a
|
|
181
183
|
* column of lower-case ones, with or without colour.
|
|
184
|
+
*
|
|
185
|
+
* `skipped` and `unknown` are one family: neither has a verdict, both wear the
|
|
186
|
+
* same tint, and only the glyph and the word tell them apart. Measured on
|
|
187
|
+
* 0.1.6: `--offline` drew the skipped validation-commit check as `▁ ok`, and
|
|
188
|
+
* `--json` said `"verdict": "pass"`, for a comparison that never happened. The
|
|
189
|
+
* skipped mark is the floor's ink at the ceiling: the same weight as a pass,
|
|
190
|
+
* visibly not landed on one, on a theme that shows no hue.
|
|
182
191
|
*/
|
|
183
192
|
export const STATUS = Object.freeze({
|
|
184
193
|
pass: Object.freeze({ glyph: DENSITY.floor, word: "ok", tint: "pass" }),
|
|
@@ -186,6 +195,7 @@ export const STATUS = Object.freeze({
|
|
|
186
195
|
advisory: Object.freeze({ glyph: DENSITY.dark, word: "note", tint: "advisory" }),
|
|
187
196
|
info: Object.freeze({ glyph: DENSITY.light, word: "info", tint: "info" }),
|
|
188
197
|
unknown: Object.freeze({ glyph: DENSITY.medium, word: "?", tint: "unknown" }),
|
|
198
|
+
skipped: Object.freeze({ glyph: DENSITY.ceiling, word: "skip", tint: "unknown" }),
|
|
189
199
|
})
|
|
190
200
|
|
|
191
201
|
/** The width of the widest mark, "█ FAIL"; every mark is padded to it so the names beside them align. */
|
|
@@ -360,7 +370,7 @@ export function wrap(text, { indent = 0, width: total = COLUMNS, first = indent
|
|
|
360
370
|
* after it starts at GUTTER. This is the only place a status is turned into
|
|
361
371
|
* characters.
|
|
362
372
|
*
|
|
363
|
-
* @param {"pass"|"fail"|"advisory"|"info"|"unknown"} state
|
|
373
|
+
* @param {"pass"|"fail"|"advisory"|"info"|"unknown"|"skipped"} state
|
|
364
374
|
* @param {(name: string, text: string) => string} c
|
|
365
375
|
*/
|
|
366
376
|
export function mark(state, c) {
|
|
@@ -56,7 +56,7 @@ export class SubmitError extends Error {
|
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
/**
|
|
59
|
-
* A check has
|
|
59
|
+
* A check has four verdicts. `pass` and `fail` are its own. `unknown` is
|
|
60
60
|
* for a check that could not run because one it depends on failed: it is
|
|
61
61
|
* rendered as a question, its detail names what it waited on, and it counts
|
|
62
62
|
* in neither `blocking` nor `advisory`, so a refusal lists root causes only.
|
|
@@ -65,19 +65,28 @@ export class SubmitError extends Error {
|
|
|
65
65
|
* checklist and official-parser each failed for want of a body nobody could
|
|
66
66
|
* render yet, and the closing refusal listed them with "not rendered" where
|
|
67
67
|
* a remedy goes.
|
|
68
|
+
*
|
|
69
|
+
* `skipped` is for a check that was not made because a flag said not to,
|
|
70
|
+
* independent of `waitedOn`: it never blocks, it counts in `skipped` and
|
|
71
|
+
* not in `unknown`, and its detail says which flag. Measured on 0.1.6:
|
|
72
|
+
* `--offline` handed `verdict: true` to the validation-commit check, so
|
|
73
|
+
* `--json` said `"verdict": "pass"` and the report drew `▁ ok` for a
|
|
74
|
+
* comparison that never happened, and an agent reading `checks` rather than
|
|
75
|
+
* the prose could tell the owner every check passed.
|
|
68
76
|
*/
|
|
69
77
|
function check(id, fields) {
|
|
70
78
|
const waitedOn = (fields.waitedOn || []).filter(Boolean)
|
|
79
|
+
const skipped = fields.skipped === true
|
|
71
80
|
return {
|
|
72
81
|
id,
|
|
73
82
|
source: fields.source,
|
|
74
83
|
why: fields.why,
|
|
75
84
|
severity: fields.severity || "blocking",
|
|
76
|
-
verdict: waitedOn.length ? "unknown" : fields.verdict ? "pass" : "fail",
|
|
77
|
-
detail: waitedOn.length ? `not checked: it needs ${waitedOn.join(" and ")} to pass first` : fields.detail || "",
|
|
85
|
+
verdict: skipped ? "skipped" : waitedOn.length ? "unknown" : fields.verdict ? "pass" : "fail",
|
|
86
|
+
detail: !skipped && waitedOn.length ? `not checked: it needs ${waitedOn.join(" and ")} to pass first` : fields.detail || "",
|
|
78
87
|
paths: fields.paths || [],
|
|
79
88
|
// One arrow, or one per cause in the order they should be read.
|
|
80
|
-
remedy: waitedOn.length ? null : Array.isArray(fields.remedy) ? (fields.remedy.length ? fields.remedy : null) : fields.remedy || null,
|
|
89
|
+
remedy: skipped || waitedOn.length ? null : Array.isArray(fields.remedy) ? (fields.remedy.length ? fields.remedy : null) : fields.remedy || null,
|
|
81
90
|
}
|
|
82
91
|
}
|
|
83
92
|
|
|
@@ -405,7 +414,8 @@ export async function submitPreflight(options) {
|
|
|
405
414
|
source: "omakit",
|
|
406
415
|
why: "The marketplace validates the default-branch HEAD it resolves when the issue is opened or edited, not the commit checked here. 73% of the 464 submissions parked in the author's court have a HEAD ahead of their validated commit, so a preflight against a commit that is not the pushed HEAD describes a tree nobody will review. Not a marketplace rule; an Omakit refusal to report on the wrong tree.",
|
|
407
416
|
severity: options.offline ? "advisory" : "blocking",
|
|
408
|
-
|
|
417
|
+
skipped: options.offline === true,
|
|
418
|
+
verdict: validationMatches === true,
|
|
409
419
|
detail: options.offline
|
|
410
420
|
? `not checked (--offline). Local commit ${subject.commit}.`
|
|
411
421
|
: head
|
|
@@ -454,6 +464,7 @@ export async function submitPreflight(options) {
|
|
|
454
464
|
const blocking = checks.filter((entry) => entry.severity === "blocking" && entry.verdict === "fail")
|
|
455
465
|
const advisory = checks.filter((entry) => entry.severity === "advisory" && entry.verdict === "fail")
|
|
456
466
|
const unknown = checks.filter((entry) => entry.verdict === "unknown")
|
|
467
|
+
const skipped = checks.filter((entry) => entry.verdict === "skipped")
|
|
457
468
|
// Three outcomes. `refused`: a blocking check failed and no body exists.
|
|
458
469
|
// `listed`: nothing failed and the plugin is already listed by this
|
|
459
470
|
// repository, so there is no body either, and nothing is wrong. `ready`:
|
|
@@ -506,6 +517,7 @@ export async function submitPreflight(options) {
|
|
|
506
517
|
blocking: blocking.map((entry) => entry.id),
|
|
507
518
|
advisory: advisory.map((entry) => entry.id),
|
|
508
519
|
unknown: unknown.map((entry) => entry.id),
|
|
520
|
+
skipped: skipped.map((entry) => entry.id),
|
|
509
521
|
issue: ready ? issue : null,
|
|
510
522
|
baseline: preflight.invoked
|
|
511
523
|
? {
|
|
@@ -118,10 +118,11 @@ export const TARGET_NOTE = "<target> is a local Git repository path, or <https u
|
|
|
118
118
|
*/
|
|
119
119
|
export const AUTHENTICATION = Object.freeze([
|
|
120
120
|
"Read-only, and optional. omakit uses your `gh` login if you have one, and",
|
|
121
|
-
"otherwise goes unauthenticated. `verify` needs no network
|
|
122
|
-
"
|
|
123
|
-
`
|
|
124
|
-
|
|
121
|
+
"otherwise goes unauthenticated. `verify` needs no network once the pin",
|
|
122
|
+
"exists, except to fetch a reviewer-mode <https url>@<sha> target, once;",
|
|
123
|
+
"`submit` reads two things online and `--offline` turns both off; `watch` and",
|
|
124
|
+
`\`parity\` are capped at ${UNAUTHENTICATED_LIMIT} requests an hour without a login. omakit never`,
|
|
125
|
+
"writes a credential anywhere.",
|
|
125
126
|
])
|
|
126
127
|
|
|
127
128
|
/**
|
|
@@ -106,12 +106,17 @@ export function validationCommentCommit(comments) {
|
|
|
106
106
|
}
|
|
107
107
|
|
|
108
108
|
/**
|
|
109
|
-
* @param {{ repoRoot: string, issueUrl: string
|
|
109
|
+
* @param {{ repoRoot: string, issueUrl: string, onPhase?: (name: string) => void,
|
|
110
|
+
* github?: { issue?: typeof issue, issueComments?: typeof issueComments, defaultBranchHead?: typeof defaultBranchHead } }} options
|
|
111
|
+
* `github` is injectable for tests, so the whole path from an issue body to
|
|
112
|
+
* a verdict can be run on data that never left the machine; the default is
|
|
113
|
+
* the read-only GitHub access in `github.mjs`.
|
|
110
114
|
*/
|
|
111
|
-
export async function validationWatch({ repoRoot, issueUrl, onPhase }) {
|
|
115
|
+
export async function validationWatch({ repoRoot, issueUrl, onPhase, github = {} }) {
|
|
112
116
|
// Optional: told the name of the step about to run, so a terminal can say
|
|
113
117
|
// what is happening while the network answers. Never affects the result.
|
|
114
118
|
const phase = onPhase || (() => {})
|
|
119
|
+
const read = { issue, issueComments, defaultBranchHead, ...github }
|
|
115
120
|
const { dir: pinDir } = requirePin(repoRoot)
|
|
116
121
|
const target = parseIssueUrl(issueUrl)
|
|
117
122
|
if (`${target.owner}/${target.repository}`.toLowerCase() !== MARKETPLACE_SLUG) {
|
|
@@ -124,14 +129,14 @@ export async function validationWatch({ repoRoot, issueUrl, onPhase }) {
|
|
|
124
129
|
const record = await loadRecord(pinDir)
|
|
125
130
|
|
|
126
131
|
phase(`reading issue #${target.number}`)
|
|
127
|
-
const subject = await issue(target.owner, target.repository, target.number)
|
|
132
|
+
const subject = await read.issue(target.owner, target.repository, target.number)
|
|
128
133
|
phase(`reading the comments on issue #${target.number}`)
|
|
129
|
-
const comments = await issueComments(target.owner, target.repository, target.number)
|
|
134
|
+
const comments = await read.issueComments(target.owner, target.repository, target.number)
|
|
130
135
|
|
|
131
|
-
const
|
|
132
|
-
const repositoryUrl =
|
|
133
|
-
const repositoryError =
|
|
134
|
-
const issueKind =
|
|
136
|
+
const repository = await repositoryFor(pinDir, subject)
|
|
137
|
+
const repositoryUrl = repository.url
|
|
138
|
+
const repositoryError = repository.error
|
|
139
|
+
const issueKind = repository.kind
|
|
135
140
|
|
|
136
141
|
let validated = null
|
|
137
142
|
let baselineError = null
|
|
@@ -164,7 +169,7 @@ export async function validationWatch({ repoRoot, issueUrl, onPhase }) {
|
|
|
164
169
|
if (repositoryUrl) {
|
|
165
170
|
phase("reading the plugin repository's default-branch HEAD")
|
|
166
171
|
try {
|
|
167
|
-
head = await defaultBranchHead(repositoryUrl)
|
|
172
|
+
head = await read.defaultBranchHead(repositoryUrl)
|
|
168
173
|
} catch (error) {
|
|
169
174
|
headError = { code: error.code || "head-unreadable", message: error.message }
|
|
170
175
|
}
|
|
@@ -202,11 +207,16 @@ export async function validationWatch({ repoRoot, issueUrl, onPhase }) {
|
|
|
202
207
|
baselineError,
|
|
203
208
|
head,
|
|
204
209
|
headError,
|
|
205
|
-
verdict: validationVerdict({ comparable, stale, validated, head, fallback, baselineError, headError, pushedAfterReview }),
|
|
210
|
+
verdict: validationVerdict({ comparable, stale, validated, head, fallback, baselineError, headError, pushedAfterReview, repositoryUrl }),
|
|
206
211
|
}
|
|
207
212
|
}
|
|
208
213
|
|
|
209
|
-
|
|
214
|
+
// `repositoryUrl` has no default on purpose. Measured on 0.1.6: the command
|
|
215
|
+
// computed it and then did not pass it, and the parameter defaulted to the
|
|
216
|
+
// truthy string "unknown", so an issue whose body named no repository was
|
|
217
|
+
// reported as a HEAD that could not be read, and the branch below that names
|
|
218
|
+
// the real cause was reachable from the unit test alone.
|
|
219
|
+
export function validationVerdict({ comparable, stale, validated, head, fallback, baselineError, headError, pushedAfterReview, repositoryUrl }) {
|
|
210
220
|
if (baselineError) {
|
|
211
221
|
return {
|
|
212
222
|
state: "unknown",
|