omakit 0.4.1 → 0.4.3
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 +32 -92
- package/package.json +1 -1
- package/skills/omarchy-plugin-check/SKILL.md +59 -3
- package/skills/omarchy-plugin-submit/SKILL.md +2 -2
- package/skills/omarchy-plugin-validation-watch/SKILL.md +4 -4
- package/tools/inspect/contract.mjs +245 -0
- package/tools/inspect/functions.mjs +129 -0
- package/tools/inspect/hosts.mjs +141 -0
- package/tools/inspect/inspect.mjs +170 -0
- package/tools/inspect/patterns.mjs +326 -0
- package/tools/inspect/processes.mjs +298 -0
- package/tools/inspect/report.mjs +342 -0
- package/tools/inspect/text.mjs +496 -0
- package/tools/inspect/timers.mjs +65 -0
- package/tools/inspect/walk.mjs +102 -0
- package/tools/inspect/writes.mjs +194 -0
- package/tools/marketplace/README.md +25 -2
- package/tools/marketplace/cli.mjs +84 -13
- package/tools/marketplace/github.mjs +13 -2
- package/tools/marketplace/local-transport.mjs +16 -4
- package/tools/marketplace/measure-review-cost.mjs +39 -0
- package/tools/marketplace/measure-staleness.mjs +76 -0
- package/tools/marketplace/options.mjs +1 -0
- package/tools/marketplace/pin.mjs +18 -10
- package/tools/marketplace/report.mjs +8 -1
- package/tools/marketplace/review-cost.mjs +107 -0
- package/tools/marketplace/run-baseline.mjs +2 -1
- package/tools/marketplace/style.mjs +3 -0
- package/tools/marketplace/submit.mjs +18 -2
- package/tools/marketplace/usage.mjs +21 -0
- package/tools/marketplace/verify.mjs +15 -4
- package/tools/marketplace/watch.mjs +64 -17
- package/tools/weigh/audit.mjs +21 -3
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// Write sites: a `FileView {` with a path and a write adapter or a write
|
|
2
|
+
// call in QML; a redirect, `tee`, `cp`, `mv`, `mkdir`, `mktemp`, `install`
|
|
3
|
+
// or `touch` in shell; `writeFile` and its variants in JavaScript; `open()`
|
|
4
|
+
// for writing in Python. Each row says whether the path's literal prefix,
|
|
5
|
+
// after the `$HOME`, `~` and `XDG_*` idioms are expanded, falls under a
|
|
6
|
+
// directory the plugin controls, and which mode the file shows for it.
|
|
7
|
+
|
|
8
|
+
import { basename, blankComments, blocks, closingBracket, lineOf, propertyValue, blankShellExpressions, shellLogicalLines, shellPieces, shellWords, stringLiteral, withoutRedirections } from "./text.mjs"
|
|
9
|
+
|
|
10
|
+
/** The directories a plugin controls, by the names the contract lists. */
|
|
11
|
+
export const CONTROLLED = Object.freeze(["$XDG_STATE_HOME", "$XDG_CACHE_HOME", "$XDG_RUNTIME_DIR"])
|
|
12
|
+
export const SHARED_TEMP = Object.freeze(["/tmp", "/var/tmp", "/dev/shm"])
|
|
13
|
+
const DEVICES = new Set(["/dev/null", "/dev/stderr", "/dev/stdout", "/dev/tty"])
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A path's literal prefix in canonical form: `~` and `$HOME` idioms,
|
|
17
|
+
* `${X}` and `Quickshell.env("X")` all read as `$X`, and the three
|
|
18
|
+
* dot-directories under the home read as the XDG names they default to.
|
|
19
|
+
* A JavaScript expression that is a `+` chain of string literals and
|
|
20
|
+
* `Quickshell.env()` calls is joined; any other expression is returned as
|
|
21
|
+
* `null`, which the classification reads as unknown.
|
|
22
|
+
*/
|
|
23
|
+
export function canonicalPath(raw) {
|
|
24
|
+
let text = String(raw).trim()
|
|
25
|
+
const literal = stringLiteral(text)
|
|
26
|
+
if (literal !== null) text = literal
|
|
27
|
+
else if (/[+]/.test(text) || /Quickshell\.env|StandardPaths/.test(text)) {
|
|
28
|
+
const pieces = text.split(/\s*\+\s*/)
|
|
29
|
+
const joined = pieces.map((piece) => {
|
|
30
|
+
const part = stringLiteral(piece)
|
|
31
|
+
if (part !== null) return part
|
|
32
|
+
const env = piece.match(/^Quickshell\.env\(\s*(["'])([A-Z_][A-Z0-9_]*)\1\s*\)$/)
|
|
33
|
+
if (env) return `$${env[2]}`
|
|
34
|
+
return null
|
|
35
|
+
})
|
|
36
|
+
if (joined.some((piece) => piece === null)) return null
|
|
37
|
+
text = joined.join("")
|
|
38
|
+
} else if (!/^[~/$.]/.test(text) && !/^[\w.-]+(?:\/[\w.-]*)*$/.test(text)) return null
|
|
39
|
+
text = text.replace(/\$\{(\w+)\}/g, "$$$1")
|
|
40
|
+
if (text === "~" || text.startsWith("~/")) text = `$HOME${text.slice(1)}`
|
|
41
|
+
text = text.replace(/^\$HOME\/\.local\/state(?=\/|$)/, "$XDG_STATE_HOME")
|
|
42
|
+
.replace(/^\$HOME\/\.cache(?=\/|$)/, "$XDG_CACHE_HOME")
|
|
43
|
+
.replace(/^\$HOME\/\.config(?=\/|$)/, "$XDG_CONFIG_HOME")
|
|
44
|
+
return text
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Whether a canonical path is under a directory the plugin controls.
|
|
49
|
+
* @returns {{ controlledDirectory: "observed"|"not-observed"|"unknown", controlledBy: string|null, temp: boolean }}
|
|
50
|
+
*/
|
|
51
|
+
export function classifyPath(path, pluginId) {
|
|
52
|
+
if (path === null || path === undefined) return { controlledDirectory: "unknown", controlledBy: null, temp: false }
|
|
53
|
+
for (const prefix of CONTROLLED) {
|
|
54
|
+
if (path === prefix || path.startsWith(`${prefix}/`)) return { controlledDirectory: "observed", controlledBy: prefix, temp: false }
|
|
55
|
+
}
|
|
56
|
+
const own = pluginId ? `$XDG_CONFIG_HOME/omarchy/plugins/${pluginId}` : null
|
|
57
|
+
if (own && (path === own || path.startsWith(`${own}/`))) return { controlledDirectory: "observed", controlledBy: own, temp: false }
|
|
58
|
+
for (const prefix of SHARED_TEMP) {
|
|
59
|
+
if (path === prefix || path.startsWith(`${prefix}/`)) return { controlledDirectory: "not-observed", controlledBy: null, temp: true }
|
|
60
|
+
}
|
|
61
|
+
if (path.startsWith("/") || path.startsWith("$HOME") || path.startsWith("$XDG_")) return { controlledDirectory: "not-observed", controlledBy: null, temp: false }
|
|
62
|
+
return { controlledDirectory: "unknown", controlledBy: null, temp: false }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function row(file, line, rawPath, via, pluginId, mode = null) {
|
|
66
|
+
const canonical = canonicalPath(rawPath)
|
|
67
|
+
const classified = classifyPath(canonical, pluginId)
|
|
68
|
+
return { file: file.path, line, path: String(rawPath).trim(), canonicalPath: canonical, via, ...classified, mode }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function qmlWrites(file, pluginId) {
|
|
72
|
+
const text = blankComments(file.text)
|
|
73
|
+
const rows = []
|
|
74
|
+
for (const block of blocks(text, "FileView")) {
|
|
75
|
+
const path = propertyValue(block.body, "path")
|
|
76
|
+
if (!path) continue
|
|
77
|
+
const writes = /writeAdapter|blockWrites|atomicWrites|setText\s*\(|(?<![\w.])write\s*\(/.test(block.body)
|
|
78
|
+
|| (block.id && new RegExp(`(?<![\\w.])${block.id}\\.(?:writeAdapter|setText|write)\\s*\\(`).test(text))
|
|
79
|
+
if (!writes) continue
|
|
80
|
+
rows.push(row(file, lineOf(text, block.open + 1 + path.offset), path.text, "FileView", pluginId))
|
|
81
|
+
}
|
|
82
|
+
rows.push(...jsWrites({ ...file, text }, pluginId, true))
|
|
83
|
+
return rows
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function jsWrites(file, pluginId, blanked = false) {
|
|
87
|
+
const text = blanked ? file.text : blankComments(file.text)
|
|
88
|
+
const rows = []
|
|
89
|
+
for (const match of text.matchAll(/(?<![\w.])(writeFileSync|writeFile|appendFileSync|appendFile)\s*\(/g)) {
|
|
90
|
+
const open = match.index + match[0].length - 1
|
|
91
|
+
const end = closingBracket(text, open)
|
|
92
|
+
if (end < 0) continue
|
|
93
|
+
const first = text.slice(open + 1, end).split(",")[0]
|
|
94
|
+
rows.push(row(file, lineOf(text, match.index), first, match[1], pluginId))
|
|
95
|
+
}
|
|
96
|
+
return rows
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function pythonWrites(file, pluginId) {
|
|
100
|
+
const rows = []
|
|
101
|
+
const lines = file.text.split("\n")
|
|
102
|
+
for (const [index, line] of lines.entries()) {
|
|
103
|
+
const code = line.split("#")[0]
|
|
104
|
+
for (const match of code.matchAll(/(?<![\w.])open\s*\(\s*([^,()]+)\s*,\s*(["'])([rwaxb+]+)\2/g)) {
|
|
105
|
+
if (!/[wax]/.test(match[3])) continue
|
|
106
|
+
rows.push(row(file, index + 1, match[1], "open", pluginId))
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return rows
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The words that are not options, and the value of `-m`/`--mode` when one is given. */
|
|
113
|
+
function operands(all) {
|
|
114
|
+
const words = withoutRedirections(all)
|
|
115
|
+
const out = []
|
|
116
|
+
let mode = null
|
|
117
|
+
for (let index = 1; index < words.length; index += 1) {
|
|
118
|
+
const word = words[index]
|
|
119
|
+
if (word === "-m" || word === "--mode") {
|
|
120
|
+
mode = words[index + 1] ?? null
|
|
121
|
+
index += 1
|
|
122
|
+
} else if (word.startsWith("--mode=")) mode = word.slice("--mode=".length)
|
|
123
|
+
else if (word.startsWith("-") && word !== "-") continue
|
|
124
|
+
else out.push(word)
|
|
125
|
+
}
|
|
126
|
+
return { operands: out, mode }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function shellWrites(file, pluginId) {
|
|
130
|
+
const rows = []
|
|
131
|
+
const chmods = []
|
|
132
|
+
let umask = null
|
|
133
|
+
for (const { line, text } of shellLogicalLines(file.text)) {
|
|
134
|
+
const trimmed = blankShellExpressions(text).trim()
|
|
135
|
+
if (!trimmed || trimmed.startsWith("#")) continue
|
|
136
|
+
for (const segment of shellPieces(trimmed)) {
|
|
137
|
+
const words = shellWords(segment.text)
|
|
138
|
+
if (!words.length) continue
|
|
139
|
+
// Redirections anywhere in the segment: `> path`, `>> path`, `>path`, `&> path`.
|
|
140
|
+
for (let i = 0; i < words.length; i += 1) {
|
|
141
|
+
const word = words[i]
|
|
142
|
+
let target = null
|
|
143
|
+
let via = null
|
|
144
|
+
if (/^(?:\d*>>?|&>>?)$/.test(word) && !/^\d*>&/.test(word)) {
|
|
145
|
+
target = words[i + 1]
|
|
146
|
+
via = word.includes(">>") ? ">>" : ">"
|
|
147
|
+
i += 1
|
|
148
|
+
} else if (/^(?:\d*>>?|&>>?)[^&\s]/.test(word)) {
|
|
149
|
+
target = word.replace(/^(?:\d*>>?|&>>?)/, "")
|
|
150
|
+
via = word.includes(">>") ? ">>" : ">"
|
|
151
|
+
}
|
|
152
|
+
// A subshell's closing paren clings to the last word; a bare number
|
|
153
|
+
// or operator where a path would be is a comparison the blanking
|
|
154
|
+
// did not reach, not a write.
|
|
155
|
+
if (target) target = target.replace(/[);]+$/, "")
|
|
156
|
+
if (target && !DEVICES.has(target) && !/^&\d$/.test(target) && !/^[\d=<>!]+$/.test(target)) rows.push(row(file, line, target, via, pluginId))
|
|
157
|
+
}
|
|
158
|
+
const command = basename(words[0])
|
|
159
|
+
if (command === "umask" && words[1]) umask = words[1]
|
|
160
|
+
if (command === "chmod" && words.length >= 3) chmods.push({ mode: words[1], paths: words.slice(2) })
|
|
161
|
+
if (["tee", "mkdir", "touch"].includes(command)) {
|
|
162
|
+
const { operands: paths, mode } = operands(words)
|
|
163
|
+
for (const path of paths) rows.push(row(file, line, path, command, pluginId, mode))
|
|
164
|
+
} else if (["cp", "mv", "install", "ln"].includes(command)) {
|
|
165
|
+
const { operands: paths, mode } = operands(words)
|
|
166
|
+
if (paths.length >= 2) rows.push(row(file, line, paths[paths.length - 1], command, pluginId, mode))
|
|
167
|
+
} else if (command === "mktemp") {
|
|
168
|
+
const { operands: paths } = operands(words)
|
|
169
|
+
const template = paths[0] || (words.includes("-p") ? `${words[words.indexOf("-p") + 1]}/tmp.XXXXXX` : "/tmp/tmp.XXXXXX")
|
|
170
|
+
rows.push(row(file, line, template, "mktemp", pluginId, words.includes("-d") ? "0700" : "0600"))
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
for (const write of rows) {
|
|
175
|
+
if (write.mode) continue
|
|
176
|
+
const chmod = chmods.find((entry) => entry.paths.includes(write.path))
|
|
177
|
+
if (chmod) write.mode = `chmod ${chmod.mode}`
|
|
178
|
+
else if (umask) write.mode = `umask ${umask}`
|
|
179
|
+
}
|
|
180
|
+
return rows
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* @param {{ path: string, kind: string, text: string }} file
|
|
185
|
+
* @param {{ pluginId?: string|null }} [context]
|
|
186
|
+
* @returns {Array} write rows, in file order
|
|
187
|
+
*/
|
|
188
|
+
export function extractWrites(file, { pluginId = null } = {}) {
|
|
189
|
+
if (file.kind === "qml") return qmlWrites(file, pluginId)
|
|
190
|
+
if (file.kind === "js") return jsWrites(file, pluginId)
|
|
191
|
+
if (file.kind === "shell") return shellWrites(file, pluginId)
|
|
192
|
+
if (file.kind === "python") return pythonWrites(file, pluginId)
|
|
193
|
+
return []
|
|
194
|
+
}
|
|
@@ -8,7 +8,7 @@ local commit through the transport seam the marketplace tests itself
|
|
|
8
8
|
| File | Purpose |
|
|
9
9
|
| --- | --- |
|
|
10
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. |
|
|
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. |
|
|
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. |
|
|
@@ -18,6 +18,8 @@ local commit through the transport seam the marketplace tests itself
|
|
|
18
18
|
| `tree.mjs` | The installable tree of a subject at one exact commit, from the Git object database. |
|
|
19
19
|
| `plugin.mjs` | The root files the submission contract needs, and the declared plugin identity. |
|
|
20
20
|
| `agent-control.mjs` | The recursive agent-control warning, and its remedy. |
|
|
21
|
+
| `review-cost.mjs` | The advisory review-cost verdict, shared account discovery, and path classification between dated validated snapshots. M4 and M9 carry its evidence. |
|
|
22
|
+
| `measure-review-cost.mjs` | Reproduces M9 across the open update population at live marketplace HEAD, with compare sources and explicit skipped reasons in JSON. |
|
|
21
23
|
| `issue.mjs` | Renders the issue the way the form would, then has the marketplace's own parser judge it. |
|
|
22
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)". |
|
|
23
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. |
|
|
@@ -53,8 +55,27 @@ machine (docs/WEIGH.md):
|
|
|
53
55
|
| `weigh/report.mjs` | The confirmation and the report for a person, drawn with `style.mjs`; the README sentence and the evidence path come last. |
|
|
54
56
|
| `weigh/confirm.mjs` | The one question, at a terminal, on stderr. |
|
|
55
57
|
|
|
58
|
+
`tools/inspect/` is `omakit inspect`, what a plugin tree does as observations
|
|
59
|
+
(docs/INSPECT.md), regular expressions over QML and shell, read-only against
|
|
60
|
+
the tree, no verdict:
|
|
61
|
+
|
|
62
|
+
| File | Purpose |
|
|
63
|
+
| --- | --- |
|
|
64
|
+
| `inspect/inspect.mjs` | The command: resolve the subject the way `submit` does, walk the tree, run the four extractors, run `verify` for the baseline, evaluate the patterns, build the document. The fixed blind-spot list lives here. |
|
|
65
|
+
| `inspect/walk.mjs` | The installable tree at the commit (through `tree.mjs`) filtered to the kinds inspect reads: `.qml`, `.js`, `.mjs`, `.cjs`, the shell extensions, `.py`, and any blob with a shebang or the executable bit; the manifest for the id; prose never read for facts. |
|
|
66
|
+
| `inspect/text.mjs` | The text primitives every extractor shares: line numbers, brace blocks, a property's value inside a block, string and array literals, the crude shell word and segment split, command substitutions, redirections. |
|
|
67
|
+
| `inspect/processes.mjs` | Process sites: `Process {` blocks with their `command:` or `<id>.command =`, `execDetached` calls, and every command segment of a shell script; argv, deadline, collector, cap, shell wrapper. |
|
|
68
|
+
| `inspect/hosts.mjs` | Every `http` or `https` literal with its host, the tool it reaches, and the timeout and size-cap flags in the same argv; a host behind an expression is not resolvable, never guessed. |
|
|
69
|
+
| `inspect/writes.mjs` | Write sites in QML (`FileView` with a write), shell (redirects, `tee`, `cp`, `mv`, `mkdir`, `mktemp`, `install`, `touch`), JavaScript and Python, with the controlled-directory test over the canonical path prefix and the mode the file shows. |
|
|
70
|
+
| `inspect/timers.mjs` | `Timer {` blocks: interval, repeat, running, triggeredOnStart, and the handler outside the block that starts it. |
|
|
71
|
+
| `inspect/functions.mjs` | Function sites: `function name(` and multi-line handlers in QML and JavaScript, shell functions, Python defs, each with its length in lines, deepest nesting and branch count. |
|
|
72
|
+
| `inspect/patterns.mjs` | The ten review classes of M11 as data: id, label, precondition over the facts, measurement, share, and the phrase for the `not observed` line. `supply-chain` cites the baseline's own findings and detects nothing. Also the M12 size thresholds as data, and the longest-first order over them. |
|
|
73
|
+
| `inspect/contract.mjs` | The JSON contract of docs/INSPECT.md as a validator, run by the unit tests over every fixture document. |
|
|
74
|
+
| `inspect/report.mjs` | The report for a person, drawn with `style.mjs` only: `░ info` for a fact, `▒ ?` for one that could not be read, `▓ note` for a pattern row, `▔ skip` under `--offline`, and the closing word `INSPECTED`. |
|
|
75
|
+
|
|
56
76
|
```text
|
|
57
77
|
omakit pin
|
|
78
|
+
omakit inspect /path/to/plugin-repo # what the tree does, as observations; --json for the document
|
|
58
79
|
omakit submit /path/to/plugin-repo # asks for the category and tags at a terminal
|
|
59
80
|
omakit submit /path/to/plugin-repo --category Widgets --tags bar,quickshell
|
|
60
81
|
omakit submit https://github.com/owner/repo@<40-char sha> --category System --tags system
|
|
@@ -98,7 +119,9 @@ sequences and nothing else.
|
|
|
98
119
|
built-in `fetch`, which does not read proxy environment variables by default.
|
|
99
120
|
Behind a proxy, run them with `NODE_USE_ENV_PROXY=1`. `submit` reads two things
|
|
100
121
|
online, the subject's default-branch HEAD and the marketplace's current
|
|
101
|
-
registry
|
|
122
|
+
registry. A manual-review baseline also reads the account's open issue
|
|
123
|
+
discovery and issue bodies for batching advice; `--offline` turns these
|
|
124
|
+
reads off. `verify` needs no network at all
|
|
102
125
|
beyond fetching a reviewer-mode subject, and `tests/parity/offline.mjs` proves
|
|
103
126
|
it.
|
|
104
127
|
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// omakit verify <target> the official baseline over the local transport, verbatim
|
|
8
8
|
// omakit parity [--count n] prove the local transport equals the GitHub transport
|
|
9
9
|
// omakit weigh <plugin> | --all what a plugin weighs on the shell, measured by restarting it
|
|
10
|
+
// omakit inspect <plugin-dir> what a plugin tree does, as observations; decides nothing
|
|
10
11
|
//
|
|
11
12
|
// Nothing here writes to the marketplace. There is no POST, PATCH, PUT or
|
|
12
13
|
// DELETE anywhere in this repository, and `tests/unit/read-only.test.mjs`
|
|
@@ -42,6 +43,8 @@ import { listWeighings } from "../weigh/list.mjs"
|
|
|
42
43
|
import { askYes } from "../weigh/confirm.mjs"
|
|
43
44
|
import { auditInstalled } from "../audit/audit.mjs"
|
|
44
45
|
import { renderAudit } from "../audit/report.mjs"
|
|
46
|
+
import { inspectPlugin, NOT_READABLE } from "../inspect/inspect.mjs"
|
|
47
|
+
import { renderInspect } from "../inspect/report.mjs"
|
|
45
48
|
|
|
46
49
|
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..")
|
|
47
50
|
|
|
@@ -68,6 +71,16 @@ const REMEDY = Object.freeze({
|
|
|
68
71
|
"interrupted": "shell.json was restored; run it again when the desktop is yours to restart.",
|
|
69
72
|
})
|
|
70
73
|
|
|
74
|
+
/*
|
|
75
|
+
* A command that has written its result leaves through `process.exitCode`,
|
|
76
|
+
* never `process.exit()`: stdout is an API, and on a pipe whose reader has
|
|
77
|
+
* not started reading yet the exit cuts the output. Measured on 0.4.1:
|
|
78
|
+
* `omakit submit <listed plugin> --json | (sleep 2; cat)` delivered 8,192 of
|
|
79
|
+
* 14,033 bytes, and a parser downstream saw invalid JSON. The failure
|
|
80
|
+
* states below still exit at once: they write one short block to stderr,
|
|
81
|
+
* and their callers use them the way a throw is used.
|
|
82
|
+
*/
|
|
83
|
+
|
|
71
84
|
/**
|
|
72
85
|
* Every failure, in one register, on stderr. `usage` errors carry the
|
|
73
86
|
* signature that was expected, so the remedy is the reference and not a
|
|
@@ -90,9 +103,21 @@ function failFrom(error) {
|
|
|
90
103
|
throw error
|
|
91
104
|
}
|
|
92
105
|
|
|
106
|
+
/**
|
|
107
|
+
* The value of a valued option, written either way the table accepts,
|
|
108
|
+
* `--name value` or `--name=value`, the last occurrence winning as it does
|
|
109
|
+
* in options.mjs. Measured on 0.4.1: the table accepted `--out=FILE` and the
|
|
110
|
+
* value was looked up as the token after `--out`, so `doctor --out=x` wrote
|
|
111
|
+
* nothing and exited 0, and `submit --category=Widgets` said the flag was
|
|
112
|
+
* missing.
|
|
113
|
+
*/
|
|
93
114
|
function option(args, name) {
|
|
94
|
-
|
|
95
|
-
|
|
115
|
+
let value
|
|
116
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
117
|
+
if (args[index] === name) value = args[index + 1]
|
|
118
|
+
else if (args[index].startsWith(`${name}=`)) value = args[index].slice(name.length + 1)
|
|
119
|
+
}
|
|
120
|
+
return value
|
|
96
121
|
}
|
|
97
122
|
|
|
98
123
|
/** The bare arguments, with every valued option's value (options.mjs, one table) left out. */
|
|
@@ -169,7 +194,8 @@ async function cmdSubmit(args) {
|
|
|
169
194
|
const usage = error.usage
|
|
170
195
|
if (json) {
|
|
171
196
|
process.stdout.write(`${JSON.stringify({ usage }, null, 2)}\n`)
|
|
172
|
-
process.
|
|
197
|
+
process.exitCode = 2
|
|
198
|
+
return
|
|
173
199
|
}
|
|
174
200
|
const flags = usage.missing.join(" and ")
|
|
175
201
|
fail("usage", `submit needs ${flags}: ${usage.missing.length === 1 ? "it is" : "they are"} an editorial choice nobody else can make, from the pinned form's own lists.`, 2,
|
|
@@ -185,7 +211,7 @@ async function cmdSubmit(args) {
|
|
|
185
211
|
emit(args, args.includes("--json") ? `${JSON.stringify(result, null, 2)}\n` : reportText(args, renderSubmit, result))
|
|
186
212
|
// Three outcomes, two exit codes: `ready` and `listed` are both healthy
|
|
187
213
|
// states, and only a refusal is a 1.
|
|
188
|
-
process.
|
|
214
|
+
process.exitCode = result.outcome === "refused" ? 1 : 0
|
|
189
215
|
}
|
|
190
216
|
|
|
191
217
|
async function cmdWatch(args) {
|
|
@@ -224,7 +250,7 @@ async function cmdWatch(args) {
|
|
|
224
250
|
spinner.done()
|
|
225
251
|
const render = result.mode === "list" ? renderWatchList : result.mode === "all" ? renderWatchAll : renderWatch
|
|
226
252
|
emit(args, args.includes("--json") ? `${JSON.stringify(result, null, 2)}\n` : reportText(args, render, result))
|
|
227
|
-
process.
|
|
253
|
+
process.exitCode = result.verdict?.state === "unknown" || result.summary?.unknown > 0 ? 2 : 0
|
|
228
254
|
}
|
|
229
255
|
|
|
230
256
|
async function cmdFrontDoor() {
|
|
@@ -243,15 +269,16 @@ async function cmdSetup(args) {
|
|
|
243
269
|
if (args.includes("--completion")) {
|
|
244
270
|
const identity = requirePin(ROOT).identity
|
|
245
271
|
const result = await completionStep({ repoRoot: ROOT, pin: identity.commit, version: VERSION, askRc: false })
|
|
246
|
-
process.
|
|
272
|
+
process.exitCode = result.state === "ok" ? 0 : 1
|
|
273
|
+
return
|
|
247
274
|
}
|
|
248
275
|
const result = await setup({ repoRoot: ROOT, entryPoint: resolve(ROOT, "bin/omakit"), yes: args.includes("--yes") })
|
|
249
|
-
process.
|
|
276
|
+
process.exitCode = result.ok ? 0 : 1
|
|
250
277
|
}
|
|
251
278
|
|
|
252
279
|
async function cmdUpgrade(args) {
|
|
253
280
|
const result = await upgrade({ repoRoot: ROOT, dryRun: args.includes("--dry-run") })
|
|
254
|
-
process.
|
|
281
|
+
process.exitCode = result.ok ? 0 : 1
|
|
255
282
|
}
|
|
256
283
|
|
|
257
284
|
async function cmdDoctor(args) {
|
|
@@ -259,7 +286,7 @@ async function cmdDoctor(args) {
|
|
|
259
286
|
const result = await doctor({ repoRoot: ROOT, offline: args.includes("--offline"), onPhase: spinner.phase })
|
|
260
287
|
spinner.done()
|
|
261
288
|
emit(args, args.includes("--json") ? `${JSON.stringify(result, null, 2)}\n` : reportText(args, renderDoctor, result))
|
|
262
|
-
process.
|
|
289
|
+
process.exitCode = result.problems ? 1 : 0
|
|
263
290
|
}
|
|
264
291
|
|
|
265
292
|
async function cmdVerify(args) {
|
|
@@ -325,7 +352,7 @@ async function cmdParity(args) {
|
|
|
325
352
|
} catch (error) {
|
|
326
353
|
failFrom(error)
|
|
327
354
|
}
|
|
328
|
-
process.
|
|
355
|
+
process.exitCode = ok ? 0 : 1
|
|
329
356
|
}
|
|
330
357
|
|
|
331
358
|
function notAudited(message, remedy = null, exit = 1) {
|
|
@@ -359,7 +386,50 @@ async function cmdAudit(args) {
|
|
|
359
386
|
}
|
|
360
387
|
if (parsed.options.has("--json")) process.stdout.write(json)
|
|
361
388
|
else process.stdout.write(`${renderAudit(document)}\n`)
|
|
362
|
-
process.
|
|
389
|
+
process.exitCode = document.ok ? 0 : 1
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* `omakit inspect`: a report, exit 0 whatever it observed; exit 2 when the
|
|
394
|
+
* target could not be read (no directory, no Git checkout, no commit, no
|
|
395
|
+
* manifest), in the one failure register. There is no exit status for
|
|
396
|
+
* "found something", because finding something is the normal outcome.
|
|
397
|
+
* `--out` writes the document to a file beside whatever stdout gets, the
|
|
398
|
+
* way `audit --out` does.
|
|
399
|
+
*/
|
|
400
|
+
async function cmdInspect(args) {
|
|
401
|
+
const parsed = checkArgs(args, ACCEPTED.inspect)
|
|
402
|
+
if (parsed.offending !== null) fail("usage", `${parsed.reason}. Accepted: ${acceptedWords("inspect")}.`, 2, "omakit inspect <plugin-dir> [--full] [--json] [--out FILE] [--offline] [--allow-dirty]")
|
|
403
|
+
const target = parsed.positionals[0]
|
|
404
|
+
if (!target) fail("usage", "inspect needs a plugin directory: `omakit inspect <plugin-dir>`", 2, "omakit inspect <plugin-dir> [--full] [--json] [--out FILE] [--offline] [--allow-dirty]")
|
|
405
|
+
const spinner = spinnerFor(args)
|
|
406
|
+
let document
|
|
407
|
+
try {
|
|
408
|
+
document = await inspectPlugin({
|
|
409
|
+
repoRoot: ROOT,
|
|
410
|
+
target,
|
|
411
|
+
offline: parsed.options.has("--offline"),
|
|
412
|
+
allowDirty: parsed.options.has("--allow-dirty"),
|
|
413
|
+
omakitVersion: VERSION,
|
|
414
|
+
onPhase: spinner.phase,
|
|
415
|
+
})
|
|
416
|
+
} catch (error) {
|
|
417
|
+
spinner.done()
|
|
418
|
+
if (error?.code && typeof error.code === "string") {
|
|
419
|
+
fail(error.code, error.message, NOT_READABLE.includes(error.code) ? 2 : 1, error.remedy || REMEDY[error.code])
|
|
420
|
+
}
|
|
421
|
+
throw error
|
|
422
|
+
}
|
|
423
|
+
spinner.done()
|
|
424
|
+
const json = `${JSON.stringify(document, null, 2)}\n`
|
|
425
|
+
const out = parsed.options.get("--out")
|
|
426
|
+
if (out) {
|
|
427
|
+
mkdirSync(dirname(resolve(out)), { recursive: true })
|
|
428
|
+
writeFileSync(resolve(out), json)
|
|
429
|
+
}
|
|
430
|
+
if (parsed.options.has("--json")) process.stdout.write(json)
|
|
431
|
+
else process.stdout.write(`${renderInspect(document, { full: parsed.options.has("--full") })}\n`)
|
|
432
|
+
process.exitCode = 0
|
|
363
433
|
}
|
|
364
434
|
|
|
365
435
|
/**
|
|
@@ -399,7 +469,7 @@ async function cmdWeigh(args) {
|
|
|
399
469
|
throw error
|
|
400
470
|
}
|
|
401
471
|
process.stdout.write(json ? `${JSON.stringify(list.rows, null, 2)}\n` : `${renderList(list)}\n`)
|
|
402
|
-
|
|
472
|
+
return
|
|
403
473
|
}
|
|
404
474
|
if (!target && !all) notWeighed("usage", "weigh needs a plugin: `omakit weigh <plugin-id-or-dir>`, or `omakit weigh --all` for every enabled third-party plugin.", "omakit weigh <plugin-id-or-dir>", 2)
|
|
405
475
|
if (target && all) notWeighed("usage", `--all weighs every enabled third-party plugin, so ${JSON.stringify(target)} is one argument more than it takes.`, "omakit weigh --all, or omakit weigh <plugin-id-or-dir>", 2)
|
|
@@ -466,7 +536,6 @@ async function cmdWeigh(args) {
|
|
|
466
536
|
} else {
|
|
467
537
|
process.stdout.write(`\n${renderWeigh(document)}\n`)
|
|
468
538
|
}
|
|
469
|
-
process.exit(0)
|
|
470
539
|
}
|
|
471
540
|
|
|
472
541
|
const VERSION = JSON.parse(readFileSync(resolve(ROOT, "package.json"), "utf8")).version
|
|
@@ -539,6 +608,8 @@ if (command === "setup") {
|
|
|
539
608
|
await cmdAudit(rest)
|
|
540
609
|
} else if (command === "weigh") {
|
|
541
610
|
await cmdWeigh(rest)
|
|
611
|
+
} else if (command === "inspect") {
|
|
612
|
+
await cmdInspect(rest)
|
|
542
613
|
} else if (command === "help" || command === "--help" || command === "-h" || command === undefined) {
|
|
543
614
|
if (rest.includes("--agent")) {
|
|
544
615
|
// The skills ship in the npm package, so this works from a global install
|
|
@@ -180,10 +180,10 @@ export async function authenticatedUser() {
|
|
|
180
180
|
}
|
|
181
181
|
|
|
182
182
|
/** Repository issues by their creator. PRs are excluded; pagination never silently truncates. */
|
|
183
|
-
export async function repositoryIssues(owner, repository, creator, { readJson = getJson, maxPages = 100 } = {}) {
|
|
183
|
+
export async function repositoryIssues(owner, repository, creator, { readJson = getJson, maxPages = 100, labels } = {}) {
|
|
184
184
|
const all = []
|
|
185
185
|
for (let page = 1; page <= maxPages; page += 1) {
|
|
186
|
-
const query = new URLSearchParams({ creator, state: "open", sort: "updated", direction: "desc", per_page: "100", page: String(page) })
|
|
186
|
+
const query = new URLSearchParams({ ...(creator ? { creator } : {}), ...(labels ? { labels } : {}), state: "open", sort: "updated", direction: "desc", per_page: "100", page: String(page) })
|
|
187
187
|
const batch = await readJson(`https://api.github.com/repos/${owner}/${repository}/issues?${query}`)
|
|
188
188
|
if (!Array.isArray(batch)) throw new GitHubError("github-unavailable", "GitHub did not return an issue list")
|
|
189
189
|
all.push(...batch.filter((item) => !item.pull_request))
|
|
@@ -205,6 +205,17 @@ export async function issueComments(owner, repository, number, maxPages = 10, re
|
|
|
205
205
|
throw new GitHubError("comments-incomplete", `Issue #${number} exceeded ${maxPages} comment pages; its latest baseline cannot be determined`)
|
|
206
206
|
}
|
|
207
207
|
|
|
208
|
+
/** Compare exact validated snapshots. The API caps its file list at 300: never classify a truncated diff. */
|
|
209
|
+
export async function compareCommits(repositoryUrl, previous, validated, { readJson = getJson } = {}) {
|
|
210
|
+
const match = String(repositoryUrl).match(/^https:\/\/github\.com\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?\/?$/)
|
|
211
|
+
if (!match || ![previous, validated].every((commit) => /^[a-f0-9]{40}$/i.test(commit))) throw new GitHubError("usage", "compare needs a repository and two full commit identifiers")
|
|
212
|
+
const url = `https://api.github.com/repos/${match[1]}/${match[2]}/compare/${previous}...${validated}?per_page=1`
|
|
213
|
+
const result = await readJson(url)
|
|
214
|
+
if (!["ahead", "identical"].includes(result?.status)) throw new GitHubError("compare-not-forward", "validated snapshots are not a forward comparison")
|
|
215
|
+
if (!Array.isArray(result.files) || result.files.length >= 300) throw new GitHubError("compare-incomplete", "compare file list unavailable or at the 300-file API limit")
|
|
216
|
+
return { url, files: result.files }
|
|
217
|
+
}
|
|
218
|
+
|
|
208
219
|
/**
|
|
209
220
|
* The current default-branch HEAD of a repository.
|
|
210
221
|
*
|
|
@@ -92,10 +92,14 @@ function fileResponse(buffer, range) {
|
|
|
92
92
|
}
|
|
93
93
|
|
|
94
94
|
/**
|
|
95
|
-
* @param {{ repoDir: string, repoUrl: string, commitSha: string, defaultBranch?: string }} options
|
|
95
|
+
* @param {{ repoDir: string, repoUrl: string, commitSha: string, defaultBranch?: string, subdir?: string }} options
|
|
96
|
+
* `subdir` names a directory below the repository root whose tree is served
|
|
97
|
+
* as the whole tree: the commit's `<sha>:<subdir>` tree, paths relative to
|
|
98
|
+
* it. `omakit inspect` uses it for a plugin kept below the root of a larger
|
|
99
|
+
* repository, so the baseline sees the plugin's tree and never the root's.
|
|
96
100
|
* @returns {{ fetchImpl: Function, stats: { api: number, raw: number } }}
|
|
97
101
|
*/
|
|
98
|
-
export function createLocalTransport({ repoDir, repoUrl, commitSha, defaultBranch }) {
|
|
102
|
+
export function createLocalTransport({ repoDir, repoUrl, commitSha, defaultBranch, subdir = "" }) {
|
|
99
103
|
const { owner, repository } = parseRepoUrl(repoUrl)
|
|
100
104
|
let commit = ""
|
|
101
105
|
try {
|
|
@@ -106,8 +110,16 @@ export function createLocalTransport({ repoDir, repoUrl, commitSha, defaultBranc
|
|
|
106
110
|
if (commit.toLowerCase() !== String(commitSha).toLowerCase()) {
|
|
107
111
|
throw new Error(`local transport: ${repoDir} does not contain commit ${commitSha}`)
|
|
108
112
|
}
|
|
109
|
-
const
|
|
110
|
-
|
|
113
|
+
const root = subdir ? `${commit}:${subdir.replace(/\/+$/, "")}` : `${commit}^{tree}`
|
|
114
|
+
let treeSha = ""
|
|
115
|
+
try {
|
|
116
|
+
treeSha = git(repoDir, ["rev-parse", "--verify", "-q", root]).trim()
|
|
117
|
+
if (git(repoDir, ["cat-file", "-t", treeSha]).trim() !== "tree") treeSha = ""
|
|
118
|
+
} catch {
|
|
119
|
+
treeSha = ""
|
|
120
|
+
}
|
|
121
|
+
if (!treeSha) throw new Error(`local transport: ${repoDir} has no directory ${subdir} at commit ${commitSha}`)
|
|
122
|
+
const tree = readTree(repoDir, treeSha)
|
|
111
123
|
const byPath = new Map(tree.map((entry) => [entry.path, entry]))
|
|
112
124
|
const branch = defaultBranch
|
|
113
125
|
|| (() => {
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Reproduce M9 with GET-only issue discovery and compare reads. No sampling.
|
|
2
|
+
import { resolve } from "node:path"
|
|
3
|
+
import { pathToFileURL } from "node:url"
|
|
4
|
+
import { MARKETPLACE_PIN } from "./pin.mjs"
|
|
5
|
+
import { repositoryIssues } from "./github.mjs"
|
|
6
|
+
import { liveRegistry } from "./registry.mjs"
|
|
7
|
+
import { reviewPolicy } from "./review-cost.mjs"
|
|
8
|
+
import { validationWatchAll } from "./watch.mjs"
|
|
9
|
+
|
|
10
|
+
export async function measureReviewCost(repoRoot) {
|
|
11
|
+
const policy = await reviewPolicy(repoRoot)
|
|
12
|
+
const registry = await liveRegistry({ repoRoot })
|
|
13
|
+
if (registry.source !== "head") throw new Error(`cannot measure at HEAD: ${registry.reason}`)
|
|
14
|
+
const [owner, repository] = new URL(MARKETPLACE_PIN.repository).pathname.slice(1).split("/")
|
|
15
|
+
const openedAt = new Date().toISOString()
|
|
16
|
+
const subjects = await repositoryIssues(owner, repository, undefined, { labels: policy.updateLabel })
|
|
17
|
+
const issues = subjects.filter((subject) => subject.state === "open" && !subject.pull_request).map((subject) => ({
|
|
18
|
+
number: subject.number, url: `${MARKETPLACE_PIN.repository}/issues/${subject.number}`, title: subject.title,
|
|
19
|
+
state: subject.state, labels: subject.labels.map((label) => typeof label === "string" ? label : label.name),
|
|
20
|
+
}))
|
|
21
|
+
// Only the manual-review subset needs comment reads and comparisons. No plugin HEAD reads.
|
|
22
|
+
const manual = issues.filter((subject) => subject.labels.includes(policy.reviewLabel))
|
|
23
|
+
const batch = await validationWatchAll({ repoRoot, discovery: { account: null, marketplace: MARKETPLACE_PIN.repository, issues: manual },
|
|
24
|
+
readRegistry: async () => registry, github: { defaultBranchHead: async () => null } })
|
|
25
|
+
const counts = batch.reviewCostSummary
|
|
26
|
+
return {
|
|
27
|
+
measurement: "M9", openedAt, completedAt: new Date().toISOString(), marketplace: MARKETPLACE_PIN.repository,
|
|
28
|
+
marketplaceHead: registry.commit, command: "node tools/marketplace/measure-review-cost.mjs", sample: false,
|
|
29
|
+
pluginUpdates: issues.length, manualQueue: manual.length, manualQueueShare: issues.length ? manual.length / issues.length : null,
|
|
30
|
+
docsOnly: counts.docsOnly, compared: counts.compared, skipped: counts.skipped.length,
|
|
31
|
+
docsOnlyShareOfCompared: counts.compared ? counts.docsOnly / counts.compared : null,
|
|
32
|
+
docsOnlyShareOfManualQueue: counts.skipped.length || !manual.length ? null : counts.docsOnly / manual.length,
|
|
33
|
+
rows: batch.issues.map((row) => ({ issue: row.issue.number, ...row.documentationDiff })),
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
|
|
38
|
+
process.stdout.write(`${JSON.stringify(await measureReviewCost(resolve(process.cwd())), null, 2)}\n`)
|
|
39
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// M6 reproduction: open author-fixes issues, bot marker against commits.atom.
|
|
2
|
+
// All remote reads use the existing GET-only client. Unknowns stay unknown.
|
|
3
|
+
import { resolve } from "node:path"
|
|
4
|
+
import { pathToFileURL } from "node:url"
|
|
5
|
+
import { MARKETPLACE_PIN } from "./pin.mjs"
|
|
6
|
+
import { repositoryIssues, getText } from "./github.mjs"
|
|
7
|
+
import { validationWatch } from "./watch.mjs"
|
|
8
|
+
|
|
9
|
+
export function atomHead(feed, source) {
|
|
10
|
+
const commit = feed.match(/<id>tag:github\.com,2008:Grit::Commit\/([0-9a-f]{40})<\/id>/i)?.[1]
|
|
11
|
+
|| feed.match(/\/commit\/([0-9a-f]{40})/i)?.[1]
|
|
12
|
+
if (!commit) throw new Error("No full HEAD commit in commits.atom")
|
|
13
|
+
return { source, commit: commit.toLowerCase(), branch: null,
|
|
14
|
+
committedAt: feed.match(/<updated>([^<]+)<\/updated>/)?.[1] || null }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function stalenessCounts(rows) {
|
|
18
|
+
const stale = rows.filter((row) => row.verdict === "stale").length
|
|
19
|
+
const current = rows.filter((row) => row.verdict === "current").length
|
|
20
|
+
const compared = stale + current
|
|
21
|
+
return { total: rows.length, compared, stale, current, unknown: rows.length - compared,
|
|
22
|
+
staleShareOfCompared: compared ? stale / compared : null,
|
|
23
|
+
staleShareOfPopulation: compared === rows.length && rows.length ? stale / rows.length : null }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function measureStaleness(repoRoot, { discover = repositoryIssues, watch = validationWatch, readText = getText } = {}) {
|
|
27
|
+
const openedAt = new Date().toISOString()
|
|
28
|
+
const [owner, repository] = new URL(MARKETPLACE_PIN.repository).pathname.slice(1).split("/")
|
|
29
|
+
const batches = await Promise.all(["needs-fixes", "security-needs-fixes"].map((labels) => discover(owner, repository, undefined, { labels })))
|
|
30
|
+
const subjects = [...new Map(batches.flat().map((subject) => [subject.number, subject])).values()]
|
|
31
|
+
.filter((subject) => subject.state === "open" && !subject.pull_request)
|
|
32
|
+
.sort((a, b) => a.number - b.number)
|
|
33
|
+
const heads = new Map()
|
|
34
|
+
function readHead(url) {
|
|
35
|
+
if (!heads.has(url)) {
|
|
36
|
+
const source = `${url.replace(/\/$/, "")}/commits.atom`
|
|
37
|
+
heads.set(url, Promise.resolve().then(async () => atomHead(await readText(source, "application/atom+xml"), source)))
|
|
38
|
+
}
|
|
39
|
+
return heads.get(url)
|
|
40
|
+
}
|
|
41
|
+
const rows = new Array(subjects.length)
|
|
42
|
+
let next = 0
|
|
43
|
+
async function worker() {
|
|
44
|
+
while (next < subjects.length) {
|
|
45
|
+
const index = next++
|
|
46
|
+
const subject = subjects[index]
|
|
47
|
+
const issueUrl = `${MARKETPLACE_PIN.repository}/issues/${subject.number}`
|
|
48
|
+
const observedAt = new Date().toISOString()
|
|
49
|
+
try {
|
|
50
|
+
const report = await watch({ repoRoot, issueUrl, github: { issue: async () => subject, defaultBranchHead: readHead } })
|
|
51
|
+
rows[index] = { issue: subject.number, issueUrl, observedAt, completedAt: new Date().toISOString(),
|
|
52
|
+
repository: report.plugin.repository, validatedCommit: report.validated?.commit || null,
|
|
53
|
+
validationSource: report.validated?.source || null, validatedAt: report.validated?.checkedAt || null,
|
|
54
|
+
validationCommentsSource: `https://api.github.com/repos/${owner}/${repository}/issues/${subject.number}/comments`,
|
|
55
|
+
defaultBranchHead: report.head?.commit || null, headSource: report.head?.source || null,
|
|
56
|
+
verdict: report.verdict.state,
|
|
57
|
+
reason: report.verdict.state === "unknown" ? report.verdict.summary : null }
|
|
58
|
+
} catch (error) {
|
|
59
|
+
rows[index] = { issue: subject.number, issueUrl, observedAt, completedAt: new Date().toISOString(),
|
|
60
|
+
repository: null, validatedCommit: null, defaultBranchHead: null, verdict: "unknown",
|
|
61
|
+
reason: `${error.code || "read-unavailable"}: ${error.message}` }
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
await Promise.all(Array.from({ length: Math.min(4, subjects.length) }, worker))
|
|
66
|
+
return { measurement: "M6", date: openedAt.slice(0, 10), openedAt, completedAt: new Date().toISOString(),
|
|
67
|
+
marketplace: MARKETPLACE_PIN.repository, marketplacePin: MARKETPLACE_PIN.commit,
|
|
68
|
+
command: "node tools/marketplace/measure-staleness.mjs", sample: false,
|
|
69
|
+
population: "All open non-PR marketplace issues labelled needs-fixes or security-needs-fixes at discovery",
|
|
70
|
+
method: "Latest bot security-baseline full commit against default-branch commits.atom HEAD; missing full markers or feeds are unknown; unequal commits are stale, without a claim about ancestry",
|
|
71
|
+
...stalenessCounts(rows), rows }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
|
|
75
|
+
process.stdout.write(`${JSON.stringify(await measureStaleness(resolve(process.cwd())), null, 2)}\n`)
|
|
76
|
+
}
|
|
@@ -28,6 +28,7 @@ export const ACCEPTED = Object.freeze({
|
|
|
28
28
|
doctor: Object.freeze({ valued: ["--out"], flags: ["--offline", "--json"], positionals: 0 }),
|
|
29
29
|
parity: Object.freeze({ valued: ["--count", "--offset", "--out"], flags: [], positionals: 0 }),
|
|
30
30
|
audit: Object.freeze({ valued: ["--out"], flags: ["--drift", "--json", "--offline"], positionals: 1 }),
|
|
31
|
+
inspect: Object.freeze({ valued: ["--out"], flags: ["--full", "--json", "--offline", "--allow-dirty"], positionals: 1 }),
|
|
31
32
|
weigh: Object.freeze({ valued: ["--runs", "--window", "--settle", "--out"], flags: ["--all", "--list", "--json", "--yes"], positionals: 1 }),
|
|
32
33
|
})
|
|
33
34
|
|