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.
@@ -0,0 +1,366 @@
1
+ // The report for a person, drawn only with the shared vocabulary of
2
+ // style.mjs: `░ info` for a fact, `▒ ?` for a fact the extraction could
3
+ // not read, `▓ note` for a pattern row, `▔ skip` for the baseline under
4
+ // --offline, and the one closing word INSPECTED. It never draws `▁ ok` or
5
+ // `█ FAIL`, because it has no verdict to attach them to. Every section
6
+ // line says what was observed, and a section with nothing in it says
7
+ // "observed nothing of this kind", never "clean".
8
+ //
9
+ // Two views of one document. The default is what needs attention, biggest
10
+ // first: one block per review class this tree shows, ordered by the
11
+ // class's share of review findings in the M11 sample, which is the one
12
+ // measured number that says how much reviewers care; under each, up to
13
+ // five sites with the fact at each in one line; classes under five percent
14
+ // counted on one line. Measured before this: a listed tree with four shell
15
+ // scripts printed 372 process rows of two lines each, and the two rows a
16
+ // reviewer would act on sat under 750 lines of argv. `--full` is the
17
+ // exhaustive view, every site of every kind with every qualifier; `--json`
18
+ // is the document itself, which carries everything either view shows.
19
+
20
+ import { colourEnabled, field, GUTTER, INSPECT_VERDICT, mark, outputColumns, styler, verdict, wrap } from "../marketplace/style.mjs"
21
+ import { withHomeAbbreviated } from "../marketplace/paths.mjs"
22
+ import { PATTERNS, SIZE } from "./patterns.mjs"
23
+ import { toolOf } from "./processes.mjs"
24
+
25
+ const NOTHING = "observed nothing of this kind"
26
+
27
+ const plural = (count, word, words = `${word}s`) => `${count} ${count === 1 ? word : words}`
28
+ const site = (row) => `${row.file}:${row.line}`
29
+
30
+ /** A share as a whole percentage; one that would round to 0 or 100 says so instead of rounding past the truth. */
31
+ function percent(share) {
32
+ const rounded = Math.round(share * 100)
33
+ if (share > 0 && rounded === 0) return "under 1%"
34
+ if (share < 1 && rounded === 100) return "over 99%"
35
+ return `${rounded}%`
36
+ }
37
+
38
+ /** The score sentence after the number: the share, then the position among the listed trees the document itself carries. */
39
+ function scoreText(size) {
40
+ const shares = size.sample.heavyShares
41
+ const rank = (shares.filter((share) => share < size.heavyShare).length / shares.length) * 100
42
+ return `${percent(size.heavyShare)} of its function lines sit in functions over the measured size, less than ${100 - Math.round(rank)} of 100 listed trees (${size.measurement})`
43
+ }
44
+
45
+ /** Under --allow-dirty: what the checkout holds that the tree at the commit does not. */
46
+ function uncommittedLine(document, c) {
47
+ const count = document.subject.uncommittedFiles
48
+ if (!count) return []
49
+ return field("uncommitted", `${plural(count, "file")} differ${count === 1 ? "s" : ""} from HEAD and ${count === 1 ? "was" : "were"} not inspected; the tree at ${document.subject.commit ? document.subject.commit.slice(0, 8) : "the commit"} is what was read`, c)
50
+ }
51
+
52
+ /** A row: a mark, the site in bold, then the text, wrapped under the gutter. */
53
+ function row(state, head, text, c, extra = []) {
54
+ const lines = wrap(text || "(nothing)", { indent: GUTTER, first: GUTTER + head.length + 2 }, c)
55
+ const out = [`${mark(state, c)}${c("name", head)} ${lines[0].trimStart()}`, ...lines.slice(1)]
56
+ for (const line of extra) out.push(...wrap(line, { indent: GUTTER }, c))
57
+ return out
58
+ }
59
+
60
+ function processRow(process, c) {
61
+ if (process.argvForm === "computed") {
62
+ return row("unknown", site(process), `command: ${process.commandText}`, c, ["argv not resolvable statically"])
63
+ }
64
+ const notes = []
65
+ notes.push(`argv ${process.argvForm}${process.declaredIn === "shell" ? " (shell line)" : ""}${process.detached ? ", detached" : ""}`)
66
+ if (process.expressions.length) notes.push(`${plural(process.expressions.length, "element")} computed (${process.expressions.map((entry) => entry.text).join(", ")})`)
67
+ if (process.shellWrapper) notes.push("shell wrapper, the script inside is not followed")
68
+ if (process.pipedFrom) notes.push(`piped from ${process.pipedFrom.argv0} on line ${process.pipedFrom.line}`)
69
+ if (process.declaredIn === "qml" && !process.detached) {
70
+ const deadline = process.deadline
71
+ notes.push(deadline.observed
72
+ ? `deadline observed (${deadline.via}${deadline.ms !== null ? ` ${deadline.ms} ms` : ""})`
73
+ : "no deadline observed")
74
+ const output = process.output
75
+ if (output.collector === "none") notes.push("no collector")
76
+ else notes.push(`output through ${output.collector}, ${output.capObserved ? `cap observed (${output.via})` : "no cap observed"}`)
77
+ } else if (process.deadline.observed) notes.push(`deadline observed (${process.deadline.via}${process.deadline.ms !== null ? ` ${process.deadline.ms} ms` : ""})`)
78
+ return row("info", site(process), argvText(process), c, [notes.join("; ")])
79
+ }
80
+
81
+ /** The argv as a list: literals quoted, a computed element as the source text it was read from. */
82
+ function argvText(process) {
83
+ const computed = new Set(process.expressions.map((entry) => entry.index))
84
+ return `[${process.argv.map((word, index) => (computed.has(index) ? word : JSON.stringify(word))).join(", ")}]`
85
+ }
86
+
87
+ function hostRow(host, c) {
88
+ const via = host.tool ? `via ${host.tool}` : "no tool observed on the line"
89
+ const notes = [
90
+ host.timeout.observed ? `timeout observed (${host.timeout.via})` : "no timeout observed",
91
+ host.sizeCap.observed ? `size cap observed (${host.sizeCap.via})` : "no size cap observed",
92
+ ]
93
+ if (host.tool === "curl") {
94
+ notes.push(host.flags.includes("-q") ? "-q observed" : "-q not observed")
95
+ notes.push(host.flags.includes("-L") || host.flags.includes("--location") ? "-L observed" : "-L not observed")
96
+ } else if (host.flags.length) notes.push(`flags ${host.flags.join(" ")}`)
97
+ if (host.privateAddress) notes.push("private or loopback address")
98
+ const lines = wrap(`${host.scheme} ${site(host)} ${via}`, { indent: GUTTER, first: GUTTER + host.host.length + 2 }, c)
99
+ return [`${mark("info", c)}${c("name", host.host)} ${lines[0].trimStart()}`, ...lines.slice(1), ...wrap(notes.join("; "), { indent: GUTTER }, c)]
100
+ }
101
+
102
+ function writeRow(write, c) {
103
+ const what = write.via === "FileView" ? `FileView path: ${write.path}` : `${write.via} ${write.path}`
104
+ const where = write.controlledDirectory === "observed"
105
+ ? `observed (${write.controlledBy})`
106
+ : write.controlledDirectory === "not-observed"
107
+ ? `not observed${write.temp ? ` (${write.canonicalPath.split("/").slice(0, 2).join("/")} is shared)` : ""}`
108
+ : "unknown (path not readable as a literal prefix)"
109
+ const notes = [`under a directory the plugin controls: ${where}`]
110
+ if (write.mode) notes.push(`mode ${write.mode}`)
111
+ return row("info", site(write), what, c, [notes.join("; ")])
112
+ }
113
+
114
+ function timerRow(timer, c) {
115
+ if (timer.intervalMs === null) {
116
+ return row("unknown", site(timer), `interval: ${timer.intervalText ?? "not declared"}`, c, ["interval not resolvable statically"])
117
+ }
118
+ const parts = [`interval ${timer.intervalMs} ms`]
119
+ parts.push(timer.repeat === true ? "repeat" : timer.repeat === false ? "single shot" : "repeat bound to an expression")
120
+ if (timer.running === true) parts.push("running")
121
+ else if (timer.running === null) parts.push("running bound to an expression")
122
+ if (timer.triggeredOnStart === true) parts.push("triggeredOnStart")
123
+ if (timer.startedBy) parts.push(`started by ${timer.startedBy}`)
124
+ else if (timer.running === false) parts.push("no start observed")
125
+ return row("info", site(timer), parts.join(", "), c)
126
+ }
127
+
128
+ function baselineLines(section, c) {
129
+ const out = []
130
+ if (section.skipped) {
131
+ out.push(...field("capabilities", "marketplace baseline not run", c))
132
+ out.push(`${mark("skipped", c)}${wrap(`skipped (${section.reason})`, { indent: GUTTER }, c)[0].trimStart()}`)
133
+ return out
134
+ }
135
+ out.push(...field("capabilities", `marketplace baseline at pin ${section.pin.commit.slice(0, 8)}, ${section.transport === "local-git" ? "local transport" : `transport ${section.transport}`}`, c))
136
+ if (!section.invoked) {
137
+ out.push(`${mark("unknown", c)}${wrap(`not run: ${section.skipReason}`, { indent: GUTTER }, c)[0].trimStart()}`)
138
+ return out
139
+ }
140
+ const official = section.official
141
+ if (official?.error) {
142
+ out.push(...row("unknown", official.error.code, official.error.message, c))
143
+ return out
144
+ }
145
+ const capabilities = official.capabilities || []
146
+ const findings = official.findings || []
147
+ const evidence = (entries) => entries.flatMap((entry) => entry.evidence || [])
148
+ const files = (entries) => [...new Set(evidence(entries).map((entry) => entry.path))]
149
+ if (capabilities.length) {
150
+ const sites = evidence(capabilities)
151
+ out.push(...wrap(`observed: ${capabilities.map((entry) => entry.id).join(", ")} (${plural(sites.length, "evidence site")}, ${files(capabilities).join(", ")})`, { indent: GUTTER, first: 0 }, c)
152
+ .map((line, index) => (index === 0 ? `${mark("info", c)}${line}` : line)))
153
+ } else {
154
+ out.push(`${mark("info", c)}observed: no capability recorded`)
155
+ }
156
+ for (const finding of findings) {
157
+ const sites = (finding.evidence || []).map((entry) => `${entry.path}:${entry.line}`)
158
+ out.push(...wrap(`observed: finding ${finding.ruleId} (${sites.join(", ")})`, { indent: GUTTER, first: 0 }, c)
159
+ .map((line, index) => (index === 0 ? `${mark("info", c)}${line}` : line)))
160
+ }
161
+ out.push(...wrap(`official result: ${official.outcome} (verbatim in --json under marketplaceBaseline)`, { indent: GUTTER }, c))
162
+ return out
163
+ }
164
+
165
+ function patternLines(document, c) {
166
+ if (!PATTERNS.length) return []
167
+ const out = []
168
+ const width = Math.max(...PATTERNS.map((pattern) => pattern.label.length)) + 1
169
+ out.push(...field("patterns", document.patterns.length
170
+ ? `of what the marketplace's human review raised, in a ${PATTERNS[0].sample} (${PATTERNS[0].measurement})`
171
+ : `none of the ${PATTERNS.length} classes the marketplace's human review raised, in a ${PATTERNS[0].sample} (${PATTERNS[0].measurement}), shows its precondition here`, c))
172
+ for (const entry of document.patterns) {
173
+ const pattern = PATTERNS.find((candidate) => candidate.id === entry.id)
174
+ const label = pattern.label.padEnd(width)
175
+ const lines = wrap(entry.observation, { indent: GUTTER, first: GUTTER + label.length + 1 }, c)
176
+ out.push(`${mark("advisory", c)}${c("name", label)} ${lines[0].trimStart()}`, ...lines.slice(1))
177
+ out.push(...wrap(`about ${Math.round(entry.share * 100)} of every 100 review findings in the sample (${entry.measurement})`, { indent: GUTTER }, c))
178
+ }
179
+ out.push("")
180
+ const lookedFor = document.lookedFor.map((id) => PATTERNS.find((pattern) => pattern.id === id)?.notObserved || id)
181
+ out.push(...field("not observed", lookedFor.length ? lookedFor.join(", ") : "every pattern's precondition was observed", c))
182
+ return out
183
+ }
184
+
185
+ /**
186
+ * The default view: what needs attention, biggest first. One block per
187
+ * review class this tree shows, ordered by the class's share of review
188
+ * findings in the M11 sample, which is the one measured number that says
189
+ * how much reviewers care; under each, the sites that show it, up to
190
+ * SHOWN_SITES, with the fact at each site in one line. Classes under
191
+ * MIN_SHARE are counted on one line and not listed. Nothing else is
192
+ * printed: the counts of what was observed go in the closing line, and
193
+ * `--full` has every site of every kind.
194
+ */
195
+ const MIN_SHARE = 0.05
196
+ const SHOWN_SITES = 5
197
+
198
+ /** A shell word for the eye: quoted only when it holds a space or a quote, an expression element as its source text. */
199
+ function commandLine(process) {
200
+ const computed = new Set(process.expressions.map((entry) => entry.index))
201
+ return process.argv.map((word, index) => (computed.has(index) || !/[\s"'`]/.test(word) ? word : JSON.stringify(word))).join(" ")
202
+ }
203
+
204
+ /**
205
+ * The fact at a site, in one line: the write there for the file class, the
206
+ * host there for the egress class, otherwise the command, then the host,
207
+ * then the write, whichever the site has.
208
+ */
209
+ function factAt(document, at, patternId) {
210
+ const same = (row) => `${row.file}:${row.line}` === at
211
+ const process = () => {
212
+ const row = document.observed.processes.find(same)
213
+ return row ? (row.argvForm === "computed" ? `command: ${row.commandText}` : commandLine(row) || "[] (an empty argv)") : ""
214
+ }
215
+ const host = () => {
216
+ const row = document.observed.hosts.find(same)
217
+ return row ? `${row.scheme}://${row.host}${row.tool ? ` via ${row.tool}` : ""}` : ""
218
+ }
219
+ const write = () => {
220
+ const row = document.observed.writes.find(same)
221
+ return row ? `${row.via} ${row.canonicalPath ?? row.path}` : ""
222
+ }
223
+ const order = patternId === "file-and-state-boundary" ? [write, process, host] : patternId === "network-egress" ? [host, process, write] : [process, host, write]
224
+ for (const fact of order) {
225
+ const text = fact()
226
+ if (text) return text
227
+ }
228
+ return ""
229
+ }
230
+
231
+ /** Text cut to a width with three dots, for a command a person only needs to recognise; --full has it whole. */
232
+ function cut(text, width) {
233
+ return text.length <= width ? text : `${text.slice(0, Math.max(0, width - 3))}...`
234
+ }
235
+
236
+ function baselineText(section) {
237
+ if (section.skipped) return `skipped (${section.reason})`
238
+ if (!section.invoked) return `not run: ${section.skipReason}`
239
+ const official = section.official
240
+ if (official?.error) return `the official code refused the snapshot: ${official.error.code}`
241
+ const names = [...(official.capabilities || []).map((entry) => entry.id), ...(official.findings || []).map((entry) => entry.ruleId)]
242
+ return `${official.outcome} at pin ${section.pin.commit.slice(0, 8)}${names.length ? `: ${names.join(", ")}` : ""}`
243
+ }
244
+
245
+ /**
246
+ * @param {object} document the document of docs/INSPECT.md
247
+ * @param {{ colour?: boolean, full?: boolean }} [options] `full` is the
248
+ * exhaustive view behind `--full`; the default is what needs attention,
249
+ * biggest first.
250
+ * @returns {string}
251
+ */
252
+ export function renderInspect(document, { colour = colourEnabled(), full = false } = {}) {
253
+ if (full) return renderFull(document, { colour })
254
+ const c = styler(colour)
255
+ const out = []
256
+ const counts = document.counts
257
+ out.push(...field("subject", `${withHomeAbbreviated(document.subject.dir)} at ${document.subject.commit ? document.subject.commit.slice(0, 8) : "no commit"}`, c))
258
+ out.push(...uncommittedLine(document, c))
259
+ out.push(...field("baseline", baselineText(document.marketplaceBaseline), c))
260
+ const score = document.size.score
261
+ out.push(...field("size score", score === null
262
+ ? `none: no function to rank`
263
+ : `${score.toFixed(2)} of 10; ${scoreText(document.size)}`, c))
264
+ out.push("")
265
+
266
+ const ranked = [...document.patterns].sort((a, b) => b.share - a.share)
267
+ const shown = ranked.filter((entry) => entry.share >= MIN_SHARE)
268
+ const below = ranked.filter((entry) => entry.share < MIN_SHARE)
269
+ const width = outputColumns()
270
+ const over = document.size.over
271
+ if (!ranked.length && !over.length) {
272
+ out.push(...field("attention", `nothing: no function over the size of ${SIZE.sample} (${SIZE.measurement}), and none of the ${PATTERNS.length} classes reviewers raise shows in this tree (${PATTERNS[0]?.measurement || "M11"})`, c))
273
+ } else {
274
+ out.push(...field("attention", `${over.length ? `long functions first, by length, then ` : ""}${plural(shown.length, "class", "classes")} reviewers raise, biggest first by share of review findings (${PATTERNS[0].measurement}); up to ${SHOWN_SITES} sites each`, c))
275
+ }
276
+ // Long functions first: what the person asked about, so the order is a
277
+ // preference and the heading says whose thresholds it uses.
278
+ if (over.length) {
279
+ out.push("")
280
+ const thresholds = `${SIZE.lines} lines, ${SIZE.branches} branches or nesting ${SIZE.depth}`
281
+ const heading = wrap(`${plural(over.length, "function")} over what 90 of 100 functions in ${SIZE.sample} stay under: ${thresholds} (${SIZE.measurement}); rank is the share of listed functions smaller than it`, { indent: GUTTER, first: GUTTER + "long functions".length + 2 }, c)
282
+ out.push(`${mark("advisory", c)}${c("name", "long functions")} ${heading[0].trimStart()}`, ...heading.slice(1))
283
+ const top = over.slice(0, SHOWN_SITES)
284
+ const siteWidth = Math.max(...top.map((entry) => `${entry.file}:${entry.line}`.length))
285
+ const nameWidth = Math.max(...top.map((entry) => entry.name.length))
286
+ for (const entry of top) {
287
+ const at = `${entry.file}:${entry.line}`
288
+ out.push(`${" ".repeat(GUTTER)}${c("name", at.padEnd(siteWidth))} ${entry.name.padEnd(nameWidth)} ${entry.lines} lines, ${entry.branches} branches, nesting ${entry.depth}, ${c("label", `rank ${Math.round(entry.percentile)}`)}`)
289
+ }
290
+ if (over.length > SHOWN_SITES) out.push(...wrap(`and ${over.length - SHOWN_SITES} more (--full)`, { indent: GUTTER }).map((line) => c("label", line)))
291
+ }
292
+ for (const entry of shown) {
293
+ const pattern = PATTERNS.find((candidate) => candidate.id === entry.id)
294
+ out.push("")
295
+ const share = `${Math.round(entry.share * 100)} of 100 findings`
296
+ const heading = wrap(entry.summary, { indent: GUTTER, first: GUTTER + pattern.label.length + 2 + share.length + 2 }, c)
297
+ out.push(`${mark("advisory", c)}${c("name", pattern.label)} ${c("label", share)} ${heading[0].trimStart()}`, ...heading.slice(1))
298
+ // A site cited twice by one class (curl from PATH and curl without -q) is one line.
299
+ const sites = [...new Set(entry.sites.map((site_) => `${site_.file}:${site_.line}`))]
300
+ const siteWidth = Math.max(...sites.slice(0, SHOWN_SITES).map((at) => at.length))
301
+ for (const at of sites.slice(0, SHOWN_SITES)) {
302
+ const fact = factAt(document, at, entry.id)
303
+ const room = width - GUTTER - siteWidth - 2
304
+ out.push(`${" ".repeat(GUTTER)}${c("name", at.padEnd(siteWidth))}${fact ? ` ${cut(fact, room)}` : ""}`)
305
+ }
306
+ if (sites.length > SHOWN_SITES) out.push(...wrap(`and ${sites.length - SHOWN_SITES} more (--full)`, { indent: GUTTER }).map((line) => c("label", line)))
307
+ }
308
+ if (below.length) {
309
+ out.push("")
310
+ out.push(...field(`under ${Math.round(MIN_SHARE * 100)}%`, below.map((entry) => `${PATTERNS.find((candidate) => candidate.id === entry.id)?.label || entry.id} (${plural(entry.observedCount, "site")})`).join(", "), c))
311
+ }
312
+ out.push("")
313
+ const processes = `${plural(counts.processes.total, "process", "processes")}${counts.processes.total ? ` (${split(counts.processes)})` : ""}`
314
+ out.push(...verdict("info", INSPECT_VERDICT, `${processes}, ${plural(counts.hosts, "host")}, ${plural(counts.writes, "write")}, ${plural(counts.timers, "timer")} observed; static; --full for every site, --json for the document`, c))
315
+ return out.join("\n")
316
+ }
317
+
318
+ /** The exhaustive view: every site, every qualifier, the whole not observed and not visible lists. */
319
+ function renderFull(document, { colour }) {
320
+ const c = styler(colour)
321
+ const out = []
322
+ const read = document.subject.filesRead
323
+ const kinds = Object.entries(read).filter(([, count]) => count > 0).map(([kind, count]) => `${count} ${kind}`)
324
+ const total = Object.values(read).reduce((sum, count) => sum + count, 0)
325
+ out.push(...field("subject", `${withHomeAbbreviated(document.subject.dir)} at ${document.subject.commit ? document.subject.commit.slice(0, 8) : "no commit"}, ${plural(total, "file")} read${kinds.length ? ` (${kinds.join(", ")})` : ""}`, c))
326
+ out.push(...uncommittedLine(document, c))
327
+ out.push(...field("method", document.method, c))
328
+ out.push("")
329
+
330
+ const sections = [
331
+ ["processes", document.observed.processes, processRow],
332
+ ["hosts", document.observed.hosts, hostRow],
333
+ ["writes", document.observed.writes, writeRow],
334
+ ["timers", document.observed.timers, timerRow],
335
+ ]
336
+ for (const [name, rows, render] of sections) {
337
+ out.push(...field(name, rows.length ? `observed ${rows.length}${name === "processes" ? `, ${split(document.counts.processes)}` : ""}` : NOTHING, c))
338
+ for (const entry of rows) out.push(...render(entry, c))
339
+ out.push("")
340
+ }
341
+ const functions = document.observed.functions
342
+ const over = document.size.over
343
+ out.push(...field("functions", functions.length
344
+ ? `observed ${functions.length}, ${over.length} over what 90 of 100 functions in ${SIZE.sample} stay under (${SIZE.lines} lines, ${SIZE.branches} branches or nesting ${SIZE.depth}; ${SIZE.measurement}), longest first`
345
+ : NOTHING, c))
346
+ for (const entry of over) out.push(...row("info", `${entry.file}:${entry.line}`, `${entry.name}, ${plural(entry.lines, "line")}, ${plural(entry.branches, "branch", "branches")}, nesting ${entry.depth}, over ${entry.percentile} of 100 listed`, c))
347
+ if (functions.length) out.push(...wrap(`size score ${document.size.score.toFixed(2)} of 10: ${scoreText(document.size)}`, { indent: GUTTER }, c))
348
+ out.push("")
349
+ out.push(...baselineLines(document.marketplaceBaseline, c))
350
+ out.push("")
351
+ const patterns = patternLines(document, c)
352
+ if (patterns.length) out.push(...patterns)
353
+ out.push(...field("not visible", document.notVisible.join(", "), c))
354
+ out.push("")
355
+ const counts = document.counts
356
+ const processes = `${plural(counts.processes.total, "process", "processes")}${counts.processes.total ? ` (${split(counts.processes)})` : ""}`
357
+ out.push(...verdict("info", INSPECT_VERDICT, `${processes}, ${plural(counts.hosts, "host")}, ${plural(counts.writes, "write")}, ${plural(counts.timers, "timer")}; static, see docs/INSPECT.md`, c))
358
+ return out.join("\n")
359
+ }
360
+
361
+ /** The process split in words: how many are QML Process sites and how many are shell lines. */
362
+ function split({ qml, shell }) {
363
+ if (!shell) return qml === 1 ? "in qml" : "all in qml"
364
+ if (!qml) return shell === 1 ? "a shell line" : "all shell lines"
365
+ return `${qml} in qml, ${plural(shell, "shell line")}`
366
+ }