omakit 0.4.2 → 0.5.0
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 +9 -3
- package/package.json +1 -1
- package/skills/omarchy-plugin-check/SKILL.md +63 -1
- package/tools/inspect/contract.mjs +256 -0
- package/tools/inspect/functions.mjs +171 -0
- package/tools/inspect/hosts.mjs +141 -0
- package/tools/inspect/inspect.mjs +182 -0
- package/tools/inspect/measure-functions.mjs +150 -0
- package/tools/inspect/patterns.mjs +355 -0
- package/tools/inspect/processes.mjs +298 -0
- package/tools/inspect/report.mjs +366 -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 +21 -1
- package/tools/marketplace/cli.mjs +49 -1
- package/tools/marketplace/local-transport.mjs +16 -4
- package/tools/marketplace/options.mjs +1 -0
- package/tools/marketplace/pin.mjs +18 -10
- package/tools/marketplace/run-baseline.mjs +2 -1
- package/tools/marketplace/style.mjs +3 -0
- package/tools/marketplace/usage.mjs +22 -0
- package/tools/marketplace/verify.mjs +15 -4
- package/tools/subject/resolve.mjs +10 -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. |
|
|
@@ -55,8 +55,28 @@ machine (docs/WEIGH.md):
|
|
|
55
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. |
|
|
56
56
|
| `weigh/confirm.mjs` | The one question, at a terminal, on stderr. |
|
|
57
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(`, named arrow functions and method shorthand in JavaScript and QML, multi-line `onSomething: {` handlers, shell functions and Python `def`s, each with its lines, deepest nesting (a literal is not a level; a Python body starts at 0) and branch count. |
|
|
72
|
+
| `inspect/measure-functions.mjs` | Reproduces M12: the first 50 listed community root-plugin trees at their validated commits, fetched read-only, `extractFunctions` over each, the quantiles, the histograms and each tree's heavy share, written to `docs/evidence/inspect/<date>-function-lengths.json`. |
|
|
73
|
+
| `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. |
|
|
74
|
+
| `inspect/contract.mjs` | The JSON contract of docs/INSPECT.md as a validator, run by the unit tests over every fixture document. |
|
|
75
|
+
| `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`. |
|
|
76
|
+
|
|
58
77
|
```text
|
|
59
78
|
omakit pin
|
|
79
|
+
omakit inspect /path/to/plugin-repo # what the tree does, as observations; --json for the document
|
|
60
80
|
omakit submit /path/to/plugin-repo # asks for the category and tags at a terminal
|
|
61
81
|
omakit submit /path/to/plugin-repo --category Widgets --tags bar,quickshell
|
|
62
82
|
omakit submit https://github.com/owner/repo@<40-char sha> --category System --tags system
|
|
@@ -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
|
|
|
@@ -55,7 +58,7 @@ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..")
|
|
|
55
58
|
const REMEDY = Object.freeze({
|
|
56
59
|
"usage": "omakit help",
|
|
57
60
|
"marketplace-unavailable": "omakit pin",
|
|
58
|
-
"dirty-worktree": "Commit the changes, or pass --allow-dirty to
|
|
61
|
+
"dirty-worktree": "Commit the changes, or pass --allow-dirty to read HEAD as committed; uncommitted edits are not read.",
|
|
59
62
|
"subject-not-found": "Pass a local Git repository path, or <https url>@<40-char sha>.",
|
|
60
63
|
"not-a-git-repository": "Pass a local Git repository path, or <https url>@<40-char sha>.",
|
|
61
64
|
"commit-not-found": "Commit first; the checks read the tree at an exact commit, never the working copy.",
|
|
@@ -386,6 +389,49 @@ async function cmdAudit(args) {
|
|
|
386
389
|
process.exitCode = document.ok ? 0 : 1
|
|
387
390
|
}
|
|
388
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
|
|
433
|
+
}
|
|
434
|
+
|
|
389
435
|
/**
|
|
390
436
|
* Every way `weigh` stops without weighing, in one register: the closing
|
|
391
437
|
* word a report would have ended with, negated, then the sentence naming
|
|
@@ -562,6 +608,8 @@ if (command === "setup") {
|
|
|
562
608
|
await cmdAudit(rest)
|
|
563
609
|
} else if (command === "weigh") {
|
|
564
610
|
await cmdWeigh(rest)
|
|
611
|
+
} else if (command === "inspect") {
|
|
612
|
+
await cmdInspect(rest)
|
|
565
613
|
} else if (command === "help" || command === "--help" || command === "-h" || command === undefined) {
|
|
566
614
|
if (rest.includes("--agent")) {
|
|
567
615
|
// The skills ship in the npm package, so this works from a global install
|
|
@@ -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
|
|| (() => {
|
|
@@ -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
|
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// whole list, and tests/unit/pin.test.mjs fails if any module starts reading a
|
|
10
10
|
// path outside it, because on a partial clone such a read would quietly reach
|
|
11
11
|
// for the network instead of failing.
|
|
12
|
-
import { execFileSync } from "node:child_process"
|
|
12
|
+
import { execFileSync, spawnSync } from "node:child_process"
|
|
13
13
|
import { existsSync, mkdirSync, writeFileSync } from "node:fs"
|
|
14
14
|
import { dirname, join, resolve } from "node:path"
|
|
15
15
|
import { omakitCacheDir } from "./paths.mjs"
|
|
@@ -178,18 +178,26 @@ export function ensurePin(repoRoot, log = () => {}, env = process.env) {
|
|
|
178
178
|
return { dir, identity, fetched: true }
|
|
179
179
|
}
|
|
180
180
|
|
|
181
|
-
/**
|
|
182
|
-
|
|
181
|
+
/**
|
|
182
|
+
* Human-readable size of the pinned checkout, for `omakit pin` and `omakit
|
|
183
|
+
* doctor`. `env` is the environment `du` is found in; injectable for tests.
|
|
184
|
+
*/
|
|
185
|
+
export function pinDiskUsage(dir, env = process.env) {
|
|
183
186
|
// -H follows a symlink given on the command line (POSIX; GNU's -D). Measured
|
|
184
187
|
// without it: a checkout reached through a symlink reported 0.0 MB, the size
|
|
185
188
|
// of the link, while the directory behind it was 15 MB.
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
189
|
+
//
|
|
190
|
+
// The total is read whether or not du exited 0. du exits 1 when a file it
|
|
191
|
+
// listed is gone by the time it reaches it, and still prints the total.
|
|
192
|
+
// Measured: with `git status` running on the pin in parallel, 1 of 40 runs
|
|
193
|
+
// warned "cannot access '.git/index.lock'" over its momentary lock file
|
|
194
|
+
// and exited 1, so doctor said "size unknown" for a checkout it had the
|
|
195
|
+
// size of. Only a run that printed no total is unknown.
|
|
196
|
+
const result = spawnSync("du", ["-skH", dir], { encoding: "utf8", env, stdio: ["ignore", "pipe", "ignore"] })
|
|
197
|
+
const output = String(result.stdout || "").trim().split(/\s+/)[0]
|
|
198
|
+
if (result.error || !/^\d+$/.test(output)) return "size unknown"
|
|
199
|
+
const mib = Number(output) / 1024
|
|
200
|
+
return `${mib < 10 ? mib.toFixed(1) : Math.round(mib)} MB on disk`
|
|
193
201
|
}
|
|
194
202
|
|
|
195
203
|
/** True when the checkout was fetched with only PIN_PATHS, as a fresh one is. */
|
|
@@ -18,7 +18,7 @@ async function loadScanner(pinDir) {
|
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
20
|
* @param {{ repoRoot: string, repoUrl: string, commitSha: string, transport: "local"|"github",
|
|
21
|
-
* repoDir?: string, listedPlugins?: Array, token?: string }} options
|
|
21
|
+
* repoDir?: string, repoSubdir?: string, listedPlugins?: Array, token?: string }} options
|
|
22
22
|
*/
|
|
23
23
|
export async function runBaseline(options) {
|
|
24
24
|
const { dir: pinDir, identity } = requirePin(options.repoRoot)
|
|
@@ -34,6 +34,7 @@ export async function runBaseline(options) {
|
|
|
34
34
|
repoDir: options.repoDir,
|
|
35
35
|
repoUrl: options.repoUrl,
|
|
36
36
|
commitSha: options.commitSha,
|
|
37
|
+
subdir: options.repoSubdir || "",
|
|
37
38
|
})
|
|
38
39
|
scanOptions.fetchImpl = transport.fetchImpl
|
|
39
40
|
adapter = {
|
|
@@ -220,6 +220,9 @@ export const AUDIT_VERDICTS = Object.freeze({
|
|
|
220
220
|
unavailable: "NOT AUDITED",
|
|
221
221
|
})
|
|
222
222
|
|
|
223
|
+
/** The closing word of `omakit inspect`: a report was produced, and it decided nothing. */
|
|
224
|
+
export const INSPECT_VERDICT = "INSPECTED"
|
|
225
|
+
|
|
223
226
|
/** The width of the widest mark, "█ FAIL"; every mark is padded to it so the names beside them align. */
|
|
224
227
|
export const MARK_WIDTH = Math.max(...Object.values(STATUS).map((s) => `${s.glyph} ${s.word}`.length))
|
|
225
228
|
|
|
@@ -91,6 +91,28 @@ export const COMMANDS = Object.freeze([
|
|
|
91
91
|
"prints the document itself, and --out writes it to a file.",
|
|
92
92
|
],
|
|
93
93
|
},
|
|
94
|
+
{
|
|
95
|
+
signature: [
|
|
96
|
+
"omakit inspect <target> [--full] [--json] [--out <file>] [--offline]",
|
|
97
|
+
" [--allow-dirty]",
|
|
98
|
+
],
|
|
99
|
+
lines: [
|
|
100
|
+
"What a plugin tree does, as observations: every process with its argv,",
|
|
101
|
+
"every host with its timeout and size-cap flags, every write with whether",
|
|
102
|
+
"it falls under a directory the plugin controls, every timer with its",
|
|
103
|
+
"interval, and the capabilities the marketplace baseline records. Below",
|
|
104
|
+
"the facts, the review classes the marketplace's human review raised,",
|
|
105
|
+
"each with its measured share, only where the tree shows the class.",
|
|
106
|
+
"Regular expressions over QML and shell, labelled observed; runs nothing",
|
|
107
|
+
"from the tree, decides nothing, exits 0 with a report and 2 when the",
|
|
108
|
+
"target cannot be read. The report opens with a size score, the share",
|
|
109
|
+
"of the tree's function lines in functions over the measured size,",
|
|
110
|
+
"placed among the listed trees' shares, 10.00 with no long function;",
|
|
111
|
+
"then what needs attention: functions over the measured size, longest",
|
|
112
|
+
"first, then the review classes by measured share, five sites each;",
|
|
113
|
+
"--full is every site with every qualifier; --json prints the document.",
|
|
114
|
+
],
|
|
115
|
+
},
|
|
94
116
|
{
|
|
95
117
|
signature: "omakit help --agent",
|
|
96
118
|
lines: [
|
|
@@ -6,15 +6,25 @@ import { runBaseline } from "./run-baseline.mjs"
|
|
|
6
6
|
import { MARKETPLACE_PIN } from "./pin.mjs"
|
|
7
7
|
import { ASSUMED_BY_ADAPTER } from "./local-transport.mjs"
|
|
8
8
|
|
|
9
|
+
/** The one assumption a subtree scan adds: the tree the official code saw is the plugin directory, not the repository root. */
|
|
10
|
+
function subtreeAssumption(subdir) {
|
|
11
|
+
return subdir ? [`tree.root=${subdir.replace(/\/+$/, "")}/ (the plugin directory below the repository root, not the root)`] : []
|
|
12
|
+
}
|
|
13
|
+
|
|
9
14
|
const MARKETPLACE_STATEMENT =
|
|
10
15
|
"Official baseline preview over a local snapshot. The marketplace rescans the public commit itself. This is not approval, listing, verification or a security audit."
|
|
11
16
|
|
|
12
17
|
/**
|
|
13
|
-
* @param {{ repoRoot: string, subject: { dir: string, commit: string, repository: { url: string|null } }, listedPlugins?: Array }} options
|
|
18
|
+
* @param {{ repoRoot: string, subject: { dir: string, commit: string, repository: { url: string|null } }, listedPlugins?: Array, subdir?: string }} options
|
|
19
|
+
* `subdir`, when given, is a directory below the repository root that the
|
|
20
|
+
* local transport serves as the whole tree, so the official code scans the
|
|
21
|
+
* plugin's directory and not the repository around it; the section records
|
|
22
|
+
* it under `assumedByAdapter`. `verify` and `submit` pass none and scan the
|
|
23
|
+
* root, which for them is the repository the marketplace would fetch.
|
|
14
24
|
* @returns the `marketplaceBaseline` section: pin, transport, adapter
|
|
15
25
|
* assumptions, the official result verbatim, and the statement.
|
|
16
26
|
*/
|
|
17
|
-
export async function marketplaceBaselineSection({ repoRoot, subject, listedPlugins }) {
|
|
27
|
+
export async function marketplaceBaselineSection({ repoRoot, subject, listedPlugins, subdir = "" }) {
|
|
18
28
|
const pin = {
|
|
19
29
|
repository: MARKETPLACE_PIN.repository,
|
|
20
30
|
commit: MARKETPLACE_PIN.commit,
|
|
@@ -40,6 +50,7 @@ export async function marketplaceBaselineSection({ repoRoot, subject, listedPlug
|
|
|
40
50
|
commitSha: subject.commit,
|
|
41
51
|
transport: "local",
|
|
42
52
|
repoDir: subject.dir,
|
|
53
|
+
repoSubdir: subdir,
|
|
43
54
|
listedPlugins,
|
|
44
55
|
})
|
|
45
56
|
} catch (error) {
|
|
@@ -49,7 +60,7 @@ export async function marketplaceBaselineSection({ repoRoot, subject, listedPlug
|
|
|
49
60
|
return {
|
|
50
61
|
pin,
|
|
51
62
|
transport: "local-git",
|
|
52
|
-
assumedByAdapter: [...ASSUMED_BY_ADAPTER],
|
|
63
|
+
assumedByAdapter: [...ASSUMED_BY_ADAPTER, ...subtreeAssumption(subdir)],
|
|
53
64
|
invoked: true,
|
|
54
65
|
skipReason: null,
|
|
55
66
|
official: { error: { code: error.code, message: error.message, ...(error.details || {}) } },
|
|
@@ -61,7 +72,7 @@ export async function marketplaceBaselineSection({ repoRoot, subject, listedPlug
|
|
|
61
72
|
return {
|
|
62
73
|
pin: { ...pin, commit: run.pin.commit, baselineVersion: run.pin.baselineVersion, enforcementMode: run.pin.enforcementMode },
|
|
63
74
|
transport: "local-git",
|
|
64
|
-
assumedByAdapter: [...run.adapter.assumedByAdapter],
|
|
75
|
+
assumedByAdapter: [...run.adapter.assumedByAdapter, ...subtreeAssumption(subdir)],
|
|
65
76
|
invoked: true,
|
|
66
77
|
skipReason: null,
|
|
67
78
|
official: run.result,
|
|
@@ -55,7 +55,8 @@ function expandHome(text) {
|
|
|
55
55
|
/**
|
|
56
56
|
* @param {string} target
|
|
57
57
|
* @param {{ cacheRoot: string, allowDirty?: boolean }} options
|
|
58
|
-
* @returns {{ mode, dir, commit, clean, repository: { kind: "git", url: string|null, declared: boolean } }}
|
|
58
|
+
* @returns {{ mode, dir, subdir, commit, clean, uncommittedFiles: number, repository: { kind: "git", url: string|null, declared: boolean } }}
|
|
59
|
+
* `uncommittedFiles` is the number of paths `git status --porcelain` lists, 0 for a clean or a fetched tree
|
|
59
60
|
*/
|
|
60
61
|
export function resolveSubject(target, options) {
|
|
61
62
|
const parsed = parseTarget(target)
|
|
@@ -73,8 +74,12 @@ export function resolveSubject(target, options) {
|
|
|
73
74
|
} catch {
|
|
74
75
|
throw new SubjectError("commit-not-found", `${top} has no commit yet`)
|
|
75
76
|
}
|
|
76
|
-
|
|
77
|
-
|
|
77
|
+
// Every check reads the tree at HEAD from the object database, so an
|
|
78
|
+
// uncommitted edit is never read; the count says how many files it
|
|
79
|
+
// leaves out, and the message says so instead of promising the tree as it is.
|
|
80
|
+
const status = git(top, ["status", "--porcelain"]).split("\n").filter((line) => line.trim().length)
|
|
81
|
+
const clean = status.length === 0
|
|
82
|
+
if (!clean && !options.allowDirty) throw new SubjectError("dirty-worktree", `${top} has uncommitted changes (${status.length} ${status.length === 1 ? "file" : "files"}); commit them, or pass --allow-dirty to read HEAD as committed; uncommitted edits are not read`)
|
|
78
83
|
let originUrl = null
|
|
79
84
|
try {
|
|
80
85
|
originUrl = git(top, ["remote", "get-url", "origin"]).trim()
|
|
@@ -88,6 +93,7 @@ export function resolveSubject(target, options) {
|
|
|
88
93
|
subdir: resolve(parsed.path) === top ? "" : resolve(parsed.path).slice(top.length + 1),
|
|
89
94
|
commit,
|
|
90
95
|
clean,
|
|
96
|
+
uncommittedFiles: status.length,
|
|
91
97
|
repository: { kind: "git", url: gh ? gh.url : null, declared: Boolean(gh) },
|
|
92
98
|
}
|
|
93
99
|
}
|
|
@@ -119,6 +125,7 @@ export function resolveSubject(target, options) {
|
|
|
119
125
|
subdir: "",
|
|
120
126
|
commit: parsed.commit,
|
|
121
127
|
clean: true,
|
|
128
|
+
uncommittedFiles: 0,
|
|
122
129
|
repository: { kind: "git", url: gh.url, declared: true },
|
|
123
130
|
}
|
|
124
131
|
}
|