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,496 @@
|
|
|
1
|
+
// The text primitives every extractor shares: line numbers, brace-delimited
|
|
2
|
+
// blocks, a property's raw value inside a block, string and array literals,
|
|
3
|
+
// and the crude shell word split. All of it is regular expressions and
|
|
4
|
+
// bracket counting over raw text, with no parser behind it, which is why
|
|
5
|
+
// every row the extractors produce is labelled observed and never claimed
|
|
6
|
+
// as complete.
|
|
7
|
+
|
|
8
|
+
/** 1-based line number of a character offset. */
|
|
9
|
+
export function lineOf(text, index) {
|
|
10
|
+
let line = 1
|
|
11
|
+
for (let i = 0; i < index && i < text.length; i += 1) if (text.charCodeAt(i) === 10) line += 1
|
|
12
|
+
return line
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* QML and JavaScript comments replaced by spaces, so a `Process {` in a
|
|
17
|
+
* comment is not a process and every offset still maps to its line.
|
|
18
|
+
*/
|
|
19
|
+
export function blankComments(text) {
|
|
20
|
+
let out = ""
|
|
21
|
+
let i = 0
|
|
22
|
+
let quote = null
|
|
23
|
+
while (i < text.length) {
|
|
24
|
+
const ch = text[i]
|
|
25
|
+
const next = text[i + 1]
|
|
26
|
+
if (quote) {
|
|
27
|
+
out += ch
|
|
28
|
+
if (ch === "\\" && i + 1 < text.length) {
|
|
29
|
+
out += next
|
|
30
|
+
i += 2
|
|
31
|
+
continue
|
|
32
|
+
}
|
|
33
|
+
if (ch === quote) quote = null
|
|
34
|
+
i += 1
|
|
35
|
+
continue
|
|
36
|
+
}
|
|
37
|
+
if (ch === '"' || ch === "'" || ch === "`") {
|
|
38
|
+
quote = ch
|
|
39
|
+
out += ch
|
|
40
|
+
i += 1
|
|
41
|
+
continue
|
|
42
|
+
}
|
|
43
|
+
if (ch === "/" && next === "/") {
|
|
44
|
+
while (i < text.length && text[i] !== "\n") {
|
|
45
|
+
out += " "
|
|
46
|
+
i += 1
|
|
47
|
+
}
|
|
48
|
+
continue
|
|
49
|
+
}
|
|
50
|
+
if (ch === "/" && next === "*") {
|
|
51
|
+
while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) {
|
|
52
|
+
out += text[i] === "\n" ? "\n" : " "
|
|
53
|
+
i += 1
|
|
54
|
+
}
|
|
55
|
+
out += " "
|
|
56
|
+
i += 2
|
|
57
|
+
continue
|
|
58
|
+
}
|
|
59
|
+
out += ch
|
|
60
|
+
i += 1
|
|
61
|
+
}
|
|
62
|
+
return out
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The offset of the bracket that closes the one at `open`, strings skipped; -1 when unbalanced. */
|
|
66
|
+
export function closingBracket(text, open) {
|
|
67
|
+
const pairs = { "{": "}", "[": "]", "(": ")" }
|
|
68
|
+
const close = pairs[text[open]]
|
|
69
|
+
let depth = 0
|
|
70
|
+
let quote = null
|
|
71
|
+
for (let i = open; i < text.length; i += 1) {
|
|
72
|
+
const ch = text[i]
|
|
73
|
+
if (quote) {
|
|
74
|
+
if (ch === "\\") i += 1
|
|
75
|
+
else if (ch === quote) quote = null
|
|
76
|
+
continue
|
|
77
|
+
}
|
|
78
|
+
if (ch === '"' || ch === "'" || ch === "`") quote = ch
|
|
79
|
+
else if (ch === text[open]) depth += 1
|
|
80
|
+
else if (ch === close) {
|
|
81
|
+
depth -= 1
|
|
82
|
+
if (depth === 0) return i
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return -1
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Every `Name {` block in a text: where it starts, its body, its line and its
|
|
90
|
+
* `id:`. `Name` is matched as a whole word, so `Process {` is found and
|
|
91
|
+
* `SubProcess {` is not. The text is expected to have its comments blanked.
|
|
92
|
+
*/
|
|
93
|
+
export function blocks(text, name) {
|
|
94
|
+
const found = []
|
|
95
|
+
const pattern = new RegExp(`(?<![\\w.])${name}\\s*\\{`, "g")
|
|
96
|
+
for (const match of text.matchAll(pattern)) {
|
|
97
|
+
const open = match.index + match[0].length - 1
|
|
98
|
+
const end = closingBracket(text, open)
|
|
99
|
+
if (end < 0) continue
|
|
100
|
+
const body = text.slice(open + 1, end)
|
|
101
|
+
found.push({ name, start: match.index, open, end, line: lineOf(text, match.index), body, id: idOf(body) })
|
|
102
|
+
}
|
|
103
|
+
return found
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The `id:` of a block body, at any depth zero position. */
|
|
107
|
+
export function idOf(body) {
|
|
108
|
+
const value = propertyValue(body, "id")
|
|
109
|
+
return value && /^[A-Za-z_]\w*$/.test(value.text) ? value.text : null
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The raw value of `name:` at depth zero of a block body: from the colon to
|
|
114
|
+
* the end of the line, or to the bracket that closes a value opening with
|
|
115
|
+
* `[`, `{` or `(`, so a multi-line array is one value. `null` when the
|
|
116
|
+
* property is not declared at that depth.
|
|
117
|
+
* @returns {{ text: string, offset: number }|null} offset is into `body`
|
|
118
|
+
*/
|
|
119
|
+
export function propertyValue(body, name) {
|
|
120
|
+
const pattern = new RegExp(`(?<![\\w.])${name}\\s*:(?!=)`, "g")
|
|
121
|
+
for (const match of body.matchAll(pattern)) {
|
|
122
|
+
if (depthAt(body, match.index) !== 0) continue
|
|
123
|
+
// `property int name: value` declares; `name: value` binds. Both are
|
|
124
|
+
// the value of the property, so both are read.
|
|
125
|
+
let start = match.index + match[0].length
|
|
126
|
+
while (start < body.length && (body[start] === " " || body[start] === "\t")) start += 1
|
|
127
|
+
const first = body[start]
|
|
128
|
+
if (first === "[" || first === "{" || first === "(") {
|
|
129
|
+
const end = closingBracket(body, start)
|
|
130
|
+
if (end > 0) return { text: body.slice(start, end + 1), offset: start }
|
|
131
|
+
}
|
|
132
|
+
// The value runs to the end of the line, or to a `;` outside quotes
|
|
133
|
+
// and brackets, since QML allows `id: a; interval: 1000` on one line.
|
|
134
|
+
let end = start
|
|
135
|
+
let quote = null
|
|
136
|
+
let depth = 0
|
|
137
|
+
while (end < body.length && body[end] !== "\n") {
|
|
138
|
+
const ch = body[end]
|
|
139
|
+
if (quote) {
|
|
140
|
+
if (ch === "\\") end += 1
|
|
141
|
+
else if (ch === quote) quote = null
|
|
142
|
+
} else if (ch === '"' || ch === "'" || ch === "`") quote = ch
|
|
143
|
+
else if (ch === "(" || ch === "[" || ch === "{") depth += 1
|
|
144
|
+
else if (ch === ")" || ch === "]" || ch === "}") depth -= 1
|
|
145
|
+
else if (ch === ";" && depth === 0) break
|
|
146
|
+
end += 1
|
|
147
|
+
}
|
|
148
|
+
return { text: body.slice(start, end).trim(), offset: start }
|
|
149
|
+
}
|
|
150
|
+
return null
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The inner text of every `$(...)` and backtick substitution in a shell
|
|
155
|
+
* line, so `t=$(mktemp)` is read as running `mktemp`. Nested substitutions
|
|
156
|
+
* are returned from the outside in.
|
|
157
|
+
*/
|
|
158
|
+
export function shellSubstitutions(line) {
|
|
159
|
+
const out = []
|
|
160
|
+
for (let i = 0; i < line.length; i += 1) {
|
|
161
|
+
if (line[i] === "$" && line[i + 1] === "(") {
|
|
162
|
+
const end = closingBracket(line, i + 1)
|
|
163
|
+
if (end > 0) {
|
|
164
|
+
out.push(line.slice(i + 2, end))
|
|
165
|
+
continue
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if (line[i] === "`") {
|
|
169
|
+
const end = line.indexOf("`", i + 1)
|
|
170
|
+
if (end > 0) {
|
|
171
|
+
out.push(line.slice(i + 1, end))
|
|
172
|
+
i = end
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return out
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Bracket depth at an offset, strings skipped. */
|
|
180
|
+
export function depthAt(text, at) {
|
|
181
|
+
let depth = 0
|
|
182
|
+
let quote = null
|
|
183
|
+
for (let i = 0; i < at; i += 1) {
|
|
184
|
+
const ch = text[i]
|
|
185
|
+
if (quote) {
|
|
186
|
+
if (ch === "\\") i += 1
|
|
187
|
+
else if (ch === quote) quote = null
|
|
188
|
+
continue
|
|
189
|
+
}
|
|
190
|
+
if (ch === '"' || ch === "'" || ch === "`") quote = ch
|
|
191
|
+
else if (ch === "{" || ch === "[" || ch === "(") depth += 1
|
|
192
|
+
else if (ch === "}" || ch === "]" || ch === ")") depth -= 1
|
|
193
|
+
}
|
|
194
|
+
return depth
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* A string literal's value, or null when the text is not one literal: a
|
|
199
|
+
* quoted string, or a template literal with no `${`. Escapes are unescaped
|
|
200
|
+
* the JavaScript way for the common cases.
|
|
201
|
+
*/
|
|
202
|
+
export function stringLiteral(text) {
|
|
203
|
+
const t = String(text).trim()
|
|
204
|
+
const match = t.match(/^(["'`])((?:\\.|(?!\1)[^\\])*)\1$/s)
|
|
205
|
+
if (!match) return null
|
|
206
|
+
if (match[1] === "`" && match[2].includes("${")) return null
|
|
207
|
+
return match[2].replace(/\\(.)/g, (_, ch) => ({ n: "\n", t: "\t", r: "\r" }[ch] ?? ch))
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* The elements of an array literal `[a, b, c]`, each with its source text
|
|
212
|
+
* and its literal value when it is one. Commas inside strings and nested
|
|
213
|
+
* brackets are not separators. `null` when the text is not an array literal.
|
|
214
|
+
*/
|
|
215
|
+
export function arrayLiteral(text) {
|
|
216
|
+
const t = String(text).trim()
|
|
217
|
+
if (!t.startsWith("[") || closingBracket(t, 0) !== t.length - 1) return null
|
|
218
|
+
const inner = t.slice(1, -1)
|
|
219
|
+
const elements = []
|
|
220
|
+
let depth = 0
|
|
221
|
+
let quote = null
|
|
222
|
+
let current = ""
|
|
223
|
+
const flush = () => {
|
|
224
|
+
const source = current.trim()
|
|
225
|
+
if (source) elements.push({ text: source, literal: stringLiteral(source) })
|
|
226
|
+
current = ""
|
|
227
|
+
}
|
|
228
|
+
for (let i = 0; i < inner.length; i += 1) {
|
|
229
|
+
const ch = inner[i]
|
|
230
|
+
if (quote) {
|
|
231
|
+
current += ch
|
|
232
|
+
if (ch === "\\" && i + 1 < inner.length) {
|
|
233
|
+
current += inner[i + 1]
|
|
234
|
+
i += 1
|
|
235
|
+
} else if (ch === quote) quote = null
|
|
236
|
+
continue
|
|
237
|
+
}
|
|
238
|
+
if (ch === '"' || ch === "'" || ch === "`") quote = ch
|
|
239
|
+
else if (ch === "[" || ch === "{" || ch === "(") depth += 1
|
|
240
|
+
else if (ch === "]" || ch === "}" || ch === ")") depth -= 1
|
|
241
|
+
else if (ch === "," && depth === 0) {
|
|
242
|
+
flush()
|
|
243
|
+
continue
|
|
244
|
+
}
|
|
245
|
+
current += ch
|
|
246
|
+
}
|
|
247
|
+
flush()
|
|
248
|
+
return elements
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* A shell line split into words the way a reader would: quotes keep a word
|
|
253
|
+
* whole and are dropped, a backslash escapes the next character, and the
|
|
254
|
+
* split is on whitespace. No expansion, no globbing: `$HOME/x` stays the
|
|
255
|
+
* text `$HOME/x`.
|
|
256
|
+
*/
|
|
257
|
+
export function shellWords(text) {
|
|
258
|
+
const words = []
|
|
259
|
+
let current = ""
|
|
260
|
+
let quote = null
|
|
261
|
+
let started = false
|
|
262
|
+
let depth = 0
|
|
263
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
264
|
+
const ch = text[i]
|
|
265
|
+
if (quote) {
|
|
266
|
+
if (ch === quote) quote = null
|
|
267
|
+
else if (ch === "\\" && quote === '"' && i + 1 < text.length) {
|
|
268
|
+
current += text[i + 1]
|
|
269
|
+
i += 1
|
|
270
|
+
} else current += ch
|
|
271
|
+
continue
|
|
272
|
+
}
|
|
273
|
+
if (ch === '"' || ch === "'") {
|
|
274
|
+
quote = ch
|
|
275
|
+
started = true
|
|
276
|
+
continue
|
|
277
|
+
}
|
|
278
|
+
if (ch === "\\" && i + 1 < text.length) {
|
|
279
|
+
current += text[i + 1]
|
|
280
|
+
started = true
|
|
281
|
+
i += 1
|
|
282
|
+
continue
|
|
283
|
+
}
|
|
284
|
+
// A `$(...)` or `(...)` is one word however many spaces it holds: the
|
|
285
|
+
// assignment `x=$(curl -fsS "$url")` is one assignment, and its command
|
|
286
|
+
// is read from the substitution on its own.
|
|
287
|
+
if (ch === "(") depth += 1
|
|
288
|
+
else if (ch === ")" && depth > 0) depth -= 1
|
|
289
|
+
if (depth === 0 && /\s/.test(ch)) {
|
|
290
|
+
if (started) words.push(current)
|
|
291
|
+
current = ""
|
|
292
|
+
started = false
|
|
293
|
+
continue
|
|
294
|
+
}
|
|
295
|
+
current += ch
|
|
296
|
+
started = true
|
|
297
|
+
}
|
|
298
|
+
if (started) words.push(current)
|
|
299
|
+
return words
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* A shell line cut at its unquoted `|`, `;`, `&&` and `||`, each piece with
|
|
304
|
+
* the operator that led into it. `2>&1` and `>&2` are not operators.
|
|
305
|
+
* @returns {Array<{ text: string, operator: string|null }>}
|
|
306
|
+
*/
|
|
307
|
+
export function shellSegments(line) {
|
|
308
|
+
const segments = []
|
|
309
|
+
let current = ""
|
|
310
|
+
let quote = null
|
|
311
|
+
let operator = null
|
|
312
|
+
let depth = 0
|
|
313
|
+
const push = (next) => {
|
|
314
|
+
if (current.trim()) segments.push({ text: current.trim(), operator })
|
|
315
|
+
current = ""
|
|
316
|
+
operator = next
|
|
317
|
+
}
|
|
318
|
+
for (let i = 0; i < line.length; i += 1) {
|
|
319
|
+
const ch = line[i]
|
|
320
|
+
const next = line[i + 1]
|
|
321
|
+
if (quote) {
|
|
322
|
+
current += ch
|
|
323
|
+
if (ch === "\\" && i + 1 < line.length) {
|
|
324
|
+
current += next
|
|
325
|
+
i += 1
|
|
326
|
+
} else if (ch === quote) quote = null
|
|
327
|
+
continue
|
|
328
|
+
}
|
|
329
|
+
if (ch === '"' || ch === "'") {
|
|
330
|
+
quote = ch
|
|
331
|
+
current += ch
|
|
332
|
+
continue
|
|
333
|
+
}
|
|
334
|
+
if (ch === "\\" && i + 1 < line.length) {
|
|
335
|
+
current += ch + next
|
|
336
|
+
i += 1
|
|
337
|
+
continue
|
|
338
|
+
}
|
|
339
|
+
// Inside `$(...)` or a subshell the operators belong to the inner
|
|
340
|
+
// command line, which is read on its own through shellPieces.
|
|
341
|
+
if (ch === "(") depth += 1
|
|
342
|
+
else if (ch === ")" && depth > 0) depth -= 1
|
|
343
|
+
if (depth > 0) {
|
|
344
|
+
current += ch
|
|
345
|
+
continue
|
|
346
|
+
}
|
|
347
|
+
if (ch === "&" && next === "&") {
|
|
348
|
+
push("&&")
|
|
349
|
+
i += 1
|
|
350
|
+
continue
|
|
351
|
+
}
|
|
352
|
+
if (ch === "|" && next === "|") {
|
|
353
|
+
push("||")
|
|
354
|
+
i += 1
|
|
355
|
+
continue
|
|
356
|
+
}
|
|
357
|
+
if (ch === "|" && next !== "&") {
|
|
358
|
+
push("|")
|
|
359
|
+
continue
|
|
360
|
+
}
|
|
361
|
+
if (ch === "|" && next === "&") {
|
|
362
|
+
push("|&")
|
|
363
|
+
i += 1
|
|
364
|
+
continue
|
|
365
|
+
}
|
|
366
|
+
if (ch === ";" && next === ";") {
|
|
367
|
+
push(";")
|
|
368
|
+
i += 1
|
|
369
|
+
continue
|
|
370
|
+
}
|
|
371
|
+
if (ch === ";" || ch === "\n") {
|
|
372
|
+
push(";")
|
|
373
|
+
continue
|
|
374
|
+
}
|
|
375
|
+
current += ch
|
|
376
|
+
}
|
|
377
|
+
push(null)
|
|
378
|
+
return segments
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** The words with every redirection and its target left out: `> path`, `2>&1`, `<file`, `&>log`. */
|
|
382
|
+
export function withoutRedirections(words) {
|
|
383
|
+
const out = []
|
|
384
|
+
for (let index = 0; index < words.length; index += 1) {
|
|
385
|
+
const word = words[index]
|
|
386
|
+
if (/^(?:\d*>>?|&>>?|<{1,3}|\d*<)$/.test(word)) {
|
|
387
|
+
index += 1
|
|
388
|
+
continue
|
|
389
|
+
}
|
|
390
|
+
if (/^(?:\d*>>?|&>>?|<{1,3}|\d*<)\S/.test(word)) continue
|
|
391
|
+
out.push(word)
|
|
392
|
+
}
|
|
393
|
+
return out
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* A shell script as logical lines, each keeping the number it started on:
|
|
398
|
+
* a backslash continuation joins the next line, a quote left open joins
|
|
399
|
+
* lines until it closes (a multi-line jq or awk program is one word), and
|
|
400
|
+
* a here-document's body is dropped, since it is data the command reads
|
|
401
|
+
* and not commands the shell runs.
|
|
402
|
+
*/
|
|
403
|
+
export function shellLogicalLines(text) {
|
|
404
|
+
const out = []
|
|
405
|
+
const lines = text.split("\n")
|
|
406
|
+
// Open when a quote, or a `$(` or `(` outside quotes, has not closed by
|
|
407
|
+
// the end of the line: the next line continues the same command.
|
|
408
|
+
const unbalanced = (line) => {
|
|
409
|
+
let quote = null
|
|
410
|
+
let depth = 0
|
|
411
|
+
for (let i = 0; i < line.length; i += 1) {
|
|
412
|
+
const ch = line[i]
|
|
413
|
+
if (quote) {
|
|
414
|
+
if (ch === "\\" && quote === '"') i += 1
|
|
415
|
+
else if (ch === quote) quote = null
|
|
416
|
+
} else if (ch === "\\") i += 1
|
|
417
|
+
else if (ch === "#" && (i === 0 || /\s/.test(line[i - 1]))) break
|
|
418
|
+
else if (ch === '"' || ch === "'") quote = ch
|
|
419
|
+
else if (ch === "(") depth += 1
|
|
420
|
+
else if (ch === ")") depth = Math.max(0, depth - 1)
|
|
421
|
+
}
|
|
422
|
+
return quote !== null || depth > 0
|
|
423
|
+
}
|
|
424
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
425
|
+
let line = lines[index]
|
|
426
|
+
const start = index + 1
|
|
427
|
+
while ((/\\$/.test(line) || unbalanced(line)) && index + 1 < lines.length) {
|
|
428
|
+
index += 1
|
|
429
|
+
line = /\\$/.test(line) ? `${line.slice(0, -1)} ${lines[index]}` : `${line}\n${lines[index]}`
|
|
430
|
+
}
|
|
431
|
+
out.push({ line: start, text: line })
|
|
432
|
+
const heredoc = line.match(/<<-?\s*(["']?)([A-Za-z_][\w-]*)\1/)
|
|
433
|
+
if (heredoc) {
|
|
434
|
+
const stop = heredoc[2]
|
|
435
|
+
while (index + 1 < lines.length && lines[index + 1].replace(/^\t+/, "") !== stop) index += 1
|
|
436
|
+
index += 1
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
return out
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Arithmetic and test contexts blanked to spaces, length kept: what sits
|
|
444
|
+
* inside `$(( ))`, `(( ))` and `[[ ]]` is an expression, so a `>` there is
|
|
445
|
+
* a comparison and a word there is not a tool.
|
|
446
|
+
*/
|
|
447
|
+
export function blankShellExpressions(line) {
|
|
448
|
+
let out = line
|
|
449
|
+
let from = 0
|
|
450
|
+
for (;;) {
|
|
451
|
+
const at = out.indexOf("((", from)
|
|
452
|
+
if (at < 0) break
|
|
453
|
+
const end = closingBracket(out, at)
|
|
454
|
+
if (end < 0) break
|
|
455
|
+
out = `${out.slice(0, at + 2)}${" ".repeat(Math.max(0, end - at - 3))}${out.slice(end - 1)}`
|
|
456
|
+
from = end + 1
|
|
457
|
+
}
|
|
458
|
+
from = 0
|
|
459
|
+
for (;;) {
|
|
460
|
+
const at = out.indexOf("[[", from)
|
|
461
|
+
if (at < 0) break
|
|
462
|
+
const end = out.indexOf("]]", at + 2)
|
|
463
|
+
if (end < 0) break
|
|
464
|
+
out = `${out.slice(0, at + 2)}${" ".repeat(end - at - 2)}${out.slice(end)}`
|
|
465
|
+
from = end + 2
|
|
466
|
+
}
|
|
467
|
+
return out
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Every command line inside a shell line, flattened: the line's own
|
|
472
|
+
* segments, then, for each, the command lines inside its `$(...)` and
|
|
473
|
+
* backtick substitutions and its `(...)` subshells, recursively, each
|
|
474
|
+
* split into segments in turn. A segment that is only a subshell is
|
|
475
|
+
* replaced by what runs inside it.
|
|
476
|
+
* @returns {Array<{ text: string, operator: string|null }>}
|
|
477
|
+
*/
|
|
478
|
+
export function shellPieces(text, depth = 0) {
|
|
479
|
+
if (depth > 8) return []
|
|
480
|
+
const out = []
|
|
481
|
+
for (const segment of shellSegments(text)) {
|
|
482
|
+
const subshell = segment.text.startsWith("(") && closingBracket(segment.text, 0) === segment.text.length - 1
|
|
483
|
+
if (subshell) {
|
|
484
|
+
out.push(...shellPieces(segment.text.slice(1, -1), depth + 1).map((piece, index) => (index === 0 ? { ...piece, operator: segment.operator } : piece)))
|
|
485
|
+
continue
|
|
486
|
+
}
|
|
487
|
+
out.push(segment)
|
|
488
|
+
for (const inner of shellSubstitutions(segment.text)) out.push(...shellPieces(inner, depth + 1))
|
|
489
|
+
}
|
|
490
|
+
return out
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** The last path segment of a command word: `/usr/bin/curl` -> `curl`. */
|
|
494
|
+
export function basename(word) {
|
|
495
|
+
return String(word).split("/").pop()
|
|
496
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Timer sites: every `Timer {` block in QML with its interval, whether it
|
|
2
|
+
// repeats, whether it runs from the start, whether it fires on start, and
|
|
3
|
+
// the handler outside the block that starts it. An interval that is an
|
|
4
|
+
// expression is recorded as not resolvable with the expression text, never
|
|
5
|
+
// as a number.
|
|
6
|
+
|
|
7
|
+
import { blankComments, blocks, closingBracket, lineOf, propertyValue } from "./text.mjs"
|
|
8
|
+
|
|
9
|
+
function flag(body, name) {
|
|
10
|
+
const value = propertyValue(body, name)?.text
|
|
11
|
+
if (value === undefined) return false
|
|
12
|
+
if (value === "true") return true
|
|
13
|
+
if (value === "false") return false
|
|
14
|
+
return null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The handler a `<id>.start()`, `.restart()` or `.running = true` sits in, by the nearest enclosing `onSomething:` or `function name(` above it. */
|
|
18
|
+
function startedBy(text, id, exclude) {
|
|
19
|
+
if (!id) return null
|
|
20
|
+
const pattern = new RegExp(`(?<![\\w.])${id}\\.(?:start\\s*\\(|restart\\s*\\(|running\\s*=\\s*true)`, "g")
|
|
21
|
+
for (const match of text.matchAll(pattern)) {
|
|
22
|
+
if (match.index >= exclude.start && match.index <= exclude.end) continue
|
|
23
|
+
const before = text.slice(0, match.index)
|
|
24
|
+
const handlers = [...before.matchAll(/(?<![\w.])(on[A-Z]\w*)\s*:|(?<![\w.])function\s+(\w+)\s*\(/g)]
|
|
25
|
+
for (let index = handlers.length - 1; index >= 0; index -= 1) {
|
|
26
|
+
const handler = handlers[index]
|
|
27
|
+
// The handler encloses the call when its value block has not closed before the call.
|
|
28
|
+
const colon = handler.index + handler[0].length
|
|
29
|
+
const rest = text.slice(colon).replace(/^\s+/, "")
|
|
30
|
+
const at = colon + (text.length - colon - rest.length)
|
|
31
|
+
const end = text[at] === "{" ? closingBracket(text, at) : text.indexOf("\n", at)
|
|
32
|
+
if (end >= match.index) return handler[1] || handler[2]
|
|
33
|
+
}
|
|
34
|
+
return "script"
|
|
35
|
+
}
|
|
36
|
+
return null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @param {{ path: string, kind: string, text: string }} file
|
|
41
|
+
* @returns {{ timers: Array, notResolvable: Array }}
|
|
42
|
+
*/
|
|
43
|
+
export function extractTimers(file) {
|
|
44
|
+
if (file.kind !== "qml") return { timers: [], notResolvable: [] }
|
|
45
|
+
const text = blankComments(file.text)
|
|
46
|
+
const timers = []
|
|
47
|
+
const notResolvable = []
|
|
48
|
+
for (const block of blocks(text, "Timer")) {
|
|
49
|
+
const interval = propertyValue(block.body, "interval")?.text ?? null
|
|
50
|
+
const intervalMs = interval !== null && /^\d+$/.test(interval) ? Number(interval) : null
|
|
51
|
+
if (interval !== null && intervalMs === null) notResolvable.push({ file: file.path, line: lineOf(text, block.open + 1 + propertyValue(block.body, "interval").offset), kind: "timer-interval", text: interval })
|
|
52
|
+
timers.push({
|
|
53
|
+
file: file.path,
|
|
54
|
+
line: block.line,
|
|
55
|
+
id: block.id,
|
|
56
|
+
intervalMs,
|
|
57
|
+
intervalText: intervalMs === null ? interval : null,
|
|
58
|
+
repeat: flag(block.body, "repeat"),
|
|
59
|
+
running: flag(block.body, "running"),
|
|
60
|
+
triggeredOnStart: flag(block.body, "triggeredOnStart"),
|
|
61
|
+
startedBy: startedBy(text, block.id, block),
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
return { timers, notResolvable }
|
|
65
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// The installable tree of the subject, filtered to what `inspect` reads for
|
|
2
|
+
// facts: QML, JavaScript, shell and Python by extension, and any other blob
|
|
3
|
+
// that opens with a shebang or carries the executable bit. Prose is never
|
|
4
|
+
// read for facts (the 0.1 Passport counted a README sentence naming `curl`
|
|
5
|
+
// as a process); `manifest.json` is read for the id and the entry points,
|
|
6
|
+
// and the README only to answer whether a privileged tool the argv names
|
|
7
|
+
// is mentioned there, which is a question about the README, not a fact
|
|
8
|
+
// about the tree.
|
|
9
|
+
//
|
|
10
|
+
// Read-only, from the Git object database at the subject commit through
|
|
11
|
+
// tree.mjs (`git ls-tree`, `git cat-file`), so a working-tree edit that is
|
|
12
|
+
// not committed is not inspected and nothing in the tree is opened as a
|
|
13
|
+
// file.
|
|
14
|
+
|
|
15
|
+
import { isBlob, readBlob, readText, readTree } from "../marketplace/tree.mjs"
|
|
16
|
+
|
|
17
|
+
/** The extensions that name a kind outright. */
|
|
18
|
+
const KIND_BY_EXTENSION = Object.freeze({
|
|
19
|
+
qml: "qml",
|
|
20
|
+
js: "js",
|
|
21
|
+
mjs: "js",
|
|
22
|
+
cjs: "js",
|
|
23
|
+
sh: "shell",
|
|
24
|
+
bash: "shell",
|
|
25
|
+
zsh: "shell",
|
|
26
|
+
fish: "shell",
|
|
27
|
+
py: "python",
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
/** The interpreter a shebang names, as a kind; anything else is `other`. */
|
|
31
|
+
export function kindOfShebang(line) {
|
|
32
|
+
const first = String(line || "").split("\n")[0]
|
|
33
|
+
if (!first.startsWith("#!")) return null
|
|
34
|
+
const words = first.slice(2).trim().split(/\s+/)
|
|
35
|
+
const interpreter = (words[0] === "/usr/bin/env" ? words[1] : words[0]) || ""
|
|
36
|
+
const name = interpreter.split("/").pop().replace(/\d+(?:\.\d+)*$/, "")
|
|
37
|
+
if (["sh", "bash", "zsh", "fish", "dash", "ksh"].includes(name)) return "shell"
|
|
38
|
+
if (name === "python") return "python"
|
|
39
|
+
if (["node", "deno", "bun"].includes(name)) return "js"
|
|
40
|
+
return "other"
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Paths whose every segment is visible: `.git/`, `.github/` and any dot entry are out, as they are for the installable tree. */
|
|
44
|
+
function visible(path) {
|
|
45
|
+
return !path.split("/").some((segment) => segment.startsWith("."))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const README = /^readme(?:\.[^/]+)?$/i
|
|
49
|
+
const EXECUTABLE = "100755"
|
|
50
|
+
const TEXT_LIMIT = 1024 * 1024
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @param {{ dir: string, commit: string, subdir?: string }} subject
|
|
54
|
+
* @returns {{
|
|
55
|
+
* files: Array<{ path: string, kind: "qml"|"js"|"shell"|"python"|"other", text: string, shebang: string|null, executable: boolean }>,
|
|
56
|
+
* filesRead: { qml: number, js: number, shell: number, python: number, other: number },
|
|
57
|
+
* manifest: object|null, manifestError: string|null, readme: string|null, entries: Array,
|
|
58
|
+
* }}
|
|
59
|
+
*/
|
|
60
|
+
export function walkSubject(subject) {
|
|
61
|
+
const prefix = subject.subdir ? `${subject.subdir}/` : ""
|
|
62
|
+
const entries = readTree(subject.dir, subject.commit)
|
|
63
|
+
.filter((entry) => isBlob(entry) && entry.path.startsWith(prefix))
|
|
64
|
+
.map((entry) => ({ ...entry, path: entry.path.slice(prefix.length) }))
|
|
65
|
+
.filter((entry) => visible(entry.path))
|
|
66
|
+
const files = []
|
|
67
|
+
let manifest = null
|
|
68
|
+
let manifestError = null
|
|
69
|
+
let readme = null
|
|
70
|
+
for (const entry of entries) {
|
|
71
|
+
if (entry.path === "manifest.json") {
|
|
72
|
+
try {
|
|
73
|
+
manifest = JSON.parse(readText(subject.dir, entry.sha))
|
|
74
|
+
} catch (error) {
|
|
75
|
+
manifestError = error.message
|
|
76
|
+
}
|
|
77
|
+
continue
|
|
78
|
+
}
|
|
79
|
+
if (!entry.path.includes("/") && README.test(entry.path)) {
|
|
80
|
+
readme = readText(subject.dir, entry.sha, TEXT_LIMIT)
|
|
81
|
+
continue
|
|
82
|
+
}
|
|
83
|
+
const extension = entry.path.includes(".") ? entry.path.split(".").pop().toLowerCase() : ""
|
|
84
|
+
let kind = KIND_BY_EXTENSION[extension] || null
|
|
85
|
+
const executable = entry.mode === EXECUTABLE
|
|
86
|
+
let shebang = null
|
|
87
|
+
if (kind || executable || entry.size <= TEXT_LIMIT) {
|
|
88
|
+
// A file without a naming extension is opened only far enough to see
|
|
89
|
+
// whether it starts with a shebang; the whole text is read for the
|
|
90
|
+
// kinds that are inspected.
|
|
91
|
+
const head = readBlob(subject.dir, entry.sha).subarray(0, 256).toString("utf8")
|
|
92
|
+
if (head.startsWith("#!")) shebang = head.split("\n")[0]
|
|
93
|
+
}
|
|
94
|
+
if (!kind && shebang) kind = kindOfShebang(shebang)
|
|
95
|
+
if (!kind && executable) kind = "other"
|
|
96
|
+
if (!kind) continue
|
|
97
|
+
files.push({ path: entry.path, kind, text: readText(subject.dir, entry.sha, TEXT_LIMIT), shebang, executable })
|
|
98
|
+
}
|
|
99
|
+
const filesRead = { qml: 0, js: 0, shell: 0, python: 0, other: 0 }
|
|
100
|
+
for (const file of files) filesRead[file.kind] += 1
|
|
101
|
+
return { files, filesRead, manifest, manifestError, readme, entries }
|
|
102
|
+
}
|