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,141 @@
1
+ // Host sites: every `http://` or `https://` literal in code or argv, with
2
+ // the host and scheme it names, the tool the literal reaches (the argv it
3
+ // sits in, or the call on its line), and the timeout and size-cap flags
4
+ // that argv shows. A host built from a variable is not a host: a literal
5
+ // whose host part holds `${...}` or stops at `://` is recorded as not
6
+ // resolvable and never guessed.
7
+
8
+ import { basename, blankComments, lineOf, shellSegments, shellWords } from "./text.mjs"
9
+ import { toolOf } from "./processes.mjs"
10
+
11
+ const URL = /https?:\/\/[^\s"'`)\]}>,;]*/g
12
+ const TIMEOUT_FLAGS = ["--max-time", "-m", "--connect-timeout", "--timeout", "-T"]
13
+ const SIZE_FLAGS = ["--max-filesize", "--quota", "-Q"]
14
+
15
+ /** Loopback, link-local and RFC 1918 literals, and the names that resolve there. */
16
+ export function privateAddress(host) {
17
+ const h = String(host).toLowerCase().replace(/^\[|\]$/g, "")
18
+ if (["localhost", "0.0.0.0", "::1", "::"].includes(h)) return true
19
+ const v4 = h.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/)
20
+ if (!v4) return /^f[cd][0-9a-f]{2}:|^fe80:/.test(h)
21
+ const [a, b] = [Number(v4[1]), Number(v4[2])]
22
+ return a === 10 || a === 127 || (a === 192 && b === 168) || (a === 172 && b >= 16 && b <= 31) || (a === 169 && b === 254) || (a === 100 && b >= 64 && b <= 127)
23
+ }
24
+
25
+ /** The host of a URL literal, or null when the text shows an expression where the host would be. */
26
+ export function hostOf(url) {
27
+ const rest = url.replace(/^https?:\/\//, "")
28
+ const authority = rest.split(/[/?#]/)[0]
29
+ if (!authority || /\$\{|\$\(|["'`+]/.test(authority)) return null
30
+ const host = authority.includes("@") ? authority.slice(authority.lastIndexOf("@") + 1) : authority
31
+ const bare = host.startsWith("[") ? host.slice(0, host.indexOf("]") + 1) : host.split(":")[0]
32
+ return bare && /^[\w.\-[\]:]+$/.test(bare) ? bare : null
33
+ }
34
+
35
+ /** The flag with its value: `--max-time 5`, `--max-time=5`, `-m5`. */
36
+ function flagValue(words, names) {
37
+ for (const [index, word] of words.entries()) {
38
+ for (const name of names) {
39
+ if (word === name) return `${name} ${words[index + 1] ?? ""}`.trim()
40
+ if (word.startsWith(`${name}=`)) return word
41
+ if (name.length === 2 && word.startsWith(name) && word.length > 2 && /\d/.test(word[2])) return word
42
+ }
43
+ }
44
+ return null
45
+ }
46
+
47
+ /** The words a URL is invoked with: a `-c` script's own words when the URL sits inside one, otherwise the argv. */
48
+ function wordsAround(process, url) {
49
+ const argv = process.argv
50
+ const at = argv.findIndex((word) => word.includes(url))
51
+ if (at < 0 || !process.shellWrapper) return argv
52
+ const script = toolOf(argv).index + 2
53
+ if (at !== script) return argv
54
+ // The URL is a word inside the script string: the words of its own segment.
55
+ for (const segment of shellSegments(argv[at])) {
56
+ if (segment.text.includes(url)) return shellWords(segment.text)
57
+ }
58
+ return shellWords(argv[at])
59
+ }
60
+
61
+ function caps(words, wholeArgv) {
62
+ const joined = wholeArgv.join(" ")
63
+ const timeout = flagValue(words, TIMEOUT_FLAGS)
64
+ || (wholeArgv.some((word) => basename(word) === "timeout") ? `timeout ${wholeArgv[wholeArgv.findIndex((word) => basename(word) === "timeout") + 1] ?? ""}`.trim() : null)
65
+ const head = joined.match(/(?:^|[\s|/])head\s+(?:-\S+\s+)*-c\s+(\S+)/)
66
+ const size = flagValue(words, SIZE_FLAGS) || (head ? `head -c ${head[1]}` : null)
67
+ return {
68
+ timeout: { observed: Boolean(timeout), via: timeout },
69
+ sizeCap: { observed: Boolean(size), via: size },
70
+ }
71
+ }
72
+
73
+ /** Shell and Python comments blanked, quotes respected, line count kept. */
74
+ function blankHashComments(text) {
75
+ return text.split("\n").map((line) => {
76
+ let quote = null
77
+ for (let i = 0; i < line.length; i += 1) {
78
+ const ch = line[i]
79
+ if (quote) {
80
+ if (ch === "\\") i += 1
81
+ else if (ch === quote) quote = null
82
+ } else if (ch === '"' || ch === "'") quote = ch
83
+ else if (ch === "#" && (i === 0 || /\s/.test(line[i - 1]))) return line.slice(0, i)
84
+ }
85
+ return line
86
+ }).join("\n")
87
+ }
88
+
89
+ function callOnLine(line, text) {
90
+ if (/(?<![\w.])fetch\s*\(/.test(line)) return "fetch"
91
+ if (/XMLHttpRequest/.test(line) || /XMLHttpRequest/.test(text)) return "XMLHttpRequest"
92
+ const call = line.match(/([A-Za-z_][\w.]*)\s*\([^()]*https?:\/\//)
93
+ return call ? call[1] : null
94
+ }
95
+
96
+ /**
97
+ * @param {{ path: string, kind: string, text: string }} file
98
+ * @param {Array} processes the process rows of the same file
99
+ * @returns {{ hosts: Array, notResolvable: Array }}
100
+ */
101
+ export function extractHosts(file, processes = []) {
102
+ const text = file.kind === "qml" || file.kind === "js" ? blankComments(file.text) : file.kind === "shell" || file.kind === "python" ? blankHashComments(file.text) : file.text
103
+ const hosts = []
104
+ const notResolvable = []
105
+ for (const match of text.matchAll(URL)) {
106
+ const url = match[0]
107
+ const line = lineOf(text, match.index)
108
+ const scheme = url.startsWith("https") ? "https" : "http"
109
+ const host = hostOf(url)
110
+ if (!host) {
111
+ notResolvable.push({ file: file.path, line, kind: "host", text: url })
112
+ continue
113
+ }
114
+ const process = processes.find((row) => row.file === file.path && Array.isArray(row.argv) && row.argv.some((word) => word.includes(url)) && (row.declaredIn !== "shell" || row.line === line))
115
+ let tool = null
116
+ let words = []
117
+ let argv = []
118
+ if (process) {
119
+ argv = process.argv
120
+ words = wordsAround(process, url)
121
+ tool = toolOf(words).tool
122
+ if (tool) tool = basename(tool)
123
+ } else {
124
+ const lineText = text.split("\n")[line - 1] || ""
125
+ tool = callOnLine(lineText, text)
126
+ }
127
+ const { timeout, sizeCap } = caps(words, argv)
128
+ hosts.push({
129
+ host,
130
+ scheme,
131
+ file: file.path,
132
+ line,
133
+ tool,
134
+ timeout,
135
+ sizeCap,
136
+ flags: words.filter((word) => word.startsWith("-") && word !== "-"),
137
+ privateAddress: privateAddress(host),
138
+ })
139
+ }
140
+ return { hosts, notResolvable }
141
+ }
@@ -0,0 +1,182 @@
1
+ // `omakit inspect <plugin-dir>`: what a plugin tree does, as observations.
2
+ // Resolve the subject the way `submit` does, read its installable tree at
3
+ // the commit, run the four extractors over every file inspect reads, run
4
+ // the marketplace's own baseline through `verify` for the capabilities,
5
+ // and build the document of docs/INSPECT.md. No verdict, and the one score
6
+ // a position among listed trees by how much of its function text is in
7
+ // long functions, never a grade: every
8
+ // row is a fact the text shows, labelled observed, and the document ends
9
+ // with what the method cannot see.
10
+ //
11
+ // Runs nothing from the tree, resolves no host, opens no socket, writes
12
+ // nothing into the tree. The one thing it fetches is what `verify` fetches,
13
+ // which under the local transport is nothing.
14
+
15
+ import { join } from "node:path"
16
+ import { resolveSubject, SubjectError } from "../subject/resolve.mjs"
17
+ import { marketplaceBaselineSection } from "../marketplace/verify.mjs"
18
+ import { consequence } from "../marketplace/preflight.mjs"
19
+ import { requirePin } from "../marketplace/pin.mjs"
20
+ import { omakitCacheDir } from "../marketplace/paths.mjs"
21
+ import { walkSubject } from "./walk.mjs"
22
+ import { extractProcesses } from "./processes.mjs"
23
+ import { extractHosts } from "./hosts.mjs"
24
+ import { extractWrites } from "./writes.mjs"
25
+ import { extractTimers } from "./timers.mjs"
26
+ import { extractFunctions } from "./functions.mjs"
27
+ import { evaluatePatterns, heavyShare, overSize, PATTERNS, rankOf, SIZE, sizeScore } from "./patterns.mjs"
28
+
29
+ export const METHOD = "static extraction, regular expressions over qml and shell; observed, not executed"
30
+
31
+ /**
32
+ * What regular expressions over QML and shell cannot see, printed at the
33
+ * end of every report and carried verbatim in the document. A tree that
34
+ * shows none of the facts is "observed nothing of this kind", never clean,
35
+ * because of this list.
36
+ */
37
+ export const NOT_VISIBLE = Object.freeze([
38
+ "commands built at run time",
39
+ "hosts and paths from variables, properties, config or the environment",
40
+ "scripts a command calls that inspect does not follow",
41
+ "components loaded from outside the tree",
42
+ "encoded or obfuscated content, and what a sh -c or eval string runs",
43
+ ])
44
+
45
+ /** The failure codes that mean the target could not be read at all: exit 2, the contract's second status. */
46
+ export const NOT_READABLE = Object.freeze(["subject-not-found", "not-a-git-repository", "commit-not-found", "nothing-to-inspect", "usage"])
47
+
48
+ export class InspectError extends Error {
49
+ constructor(code, message, remedy = null) {
50
+ super(message)
51
+ this.name = "InspectError"
52
+ this.code = code
53
+ this.remedy = remedy
54
+ }
55
+ }
56
+
57
+ /**
58
+ * @param {{ repoRoot: string, target: string, offline?: boolean, allowDirty?: boolean, omakitVersion: string, onPhase?: (text: string) => void, cacheRoot?: string }} options
59
+ * @returns {Promise<object>} the document of docs/INSPECT.md
60
+ */
61
+ export async function inspectPlugin({ repoRoot, target, offline = false, allowDirty = false, omakitVersion, onPhase = () => {}, cacheRoot = omakitCacheDir() }) {
62
+ let subject
63
+ try {
64
+ subject = resolveSubject(target, { cacheRoot, allowDirty })
65
+ } catch (error) {
66
+ if (error instanceof SubjectError && error.code === "dirty-worktree") {
67
+ throw new InspectError(error.code, error.message.replace("read HEAD as committed", "inspect HEAD as committed"), "Commit them, or pass --allow-dirty to inspect HEAD as committed; uncommitted edits are not read.")
68
+ }
69
+ if (error instanceof SubjectError) throw new InspectError(error.code, error.message)
70
+ throw error
71
+ }
72
+ onPhase("reading the installable tree")
73
+ const tree = walkSubject(subject)
74
+ const dir = subject.subdir ? join(subject.dir, subject.subdir) : subject.dir
75
+ if (!tree.manifest && !tree.manifestError) {
76
+ throw new InspectError("nothing-to-inspect", `no manifest.json at the root of ${dir}, so there is no plugin to inspect`, "Pass the directory that holds the plugin's manifest.json.")
77
+ }
78
+ const pluginId = typeof tree.manifest?.id === "string" ? tree.manifest.id.trim() : null
79
+
80
+ onPhase("reading processes, hosts, writes and timers")
81
+ const processes = []
82
+ const hosts = []
83
+ const writes = []
84
+ const timers = []
85
+ const functions = []
86
+ const notResolvable = []
87
+ for (const file of tree.files) {
88
+ // Each function carries its rank among the listed ones (M12).
89
+ functions.push(...extractFunctions(file).map((entry) => ({ ...entry, percentile: rankOf(entry) })))
90
+ const rows = extractProcesses(file)
91
+ processes.push(...rows)
92
+ for (const row of rows) {
93
+ if (row.argvForm === "computed") notResolvable.push({ file: row.file, line: row.line, kind: "command", text: `command: ${row.commandText}` })
94
+ }
95
+ const found = extractHosts(file, rows)
96
+ hosts.push(...found.hosts)
97
+ notResolvable.push(...found.notResolvable)
98
+ writes.push(...extractWrites(file, { pluginId }))
99
+ const ticking = extractTimers(file)
100
+ timers.push(...ticking.timers)
101
+ notResolvable.push(...ticking.notResolvable)
102
+ }
103
+
104
+ let marketplaceBaseline
105
+ let blockingRules = []
106
+ if (offline) {
107
+ marketplaceBaseline = { skipped: true, reason: "--offline" }
108
+ } else {
109
+ onPhase("running the official security baseline over a local snapshot")
110
+ // The baseline sees the plugin's tree and nothing around it: for a plugin
111
+ // kept below the root of a larger repository, the local transport serves
112
+ // that directory's tree as the whole tree. Without this the baseline
113
+ // scanned the repository root and reported the root's evidence as the
114
+ // plugin's, which is the 0.1 Passport's first failure.
115
+ marketplaceBaseline = await marketplaceBaselineSection({ repoRoot, subject, subdir: subject.subdir })
116
+ if (marketplaceBaseline.invoked && marketplaceBaseline.official && !marketplaceBaseline.official.error) {
117
+ blockingRules = (await consequence(requirePin(repoRoot).dir, marketplaceBaseline.official)).selectivelyBlockingRules
118
+ }
119
+ }
120
+
121
+ const facts = { processes, hosts, writes, timers, notResolvable, files: tree.files, readme: tree.readme, baseline: marketplaceBaseline, blockingRules }
122
+ const { patterns, lookedFor } = evaluatePatterns(facts)
123
+
124
+ return {
125
+ omakit: omakitVersion,
126
+ command: "inspect",
127
+ method: METHOD,
128
+ subject: {
129
+ dir,
130
+ commit: subject.commit,
131
+ repository: { url: subject.repository.url },
132
+ mode: subject.mode,
133
+ pluginId,
134
+ filesRead: tree.filesRead,
135
+ // Paths `git status` lists at the checkout, under --allow-dirty: the
136
+ // tree was read at the commit, so these were not inspected. 0 for a
137
+ // clean checkout and for a fetched commit.
138
+ uncommittedFiles: subject.uncommittedFiles,
139
+ },
140
+ observed: { processes, hosts, writes, timers, functions },
141
+ // Size: the functions over the M12 thresholds, longest first. A count of
142
+ // lines, branches and nesting over the text, compared with what 90 of
143
+ // 100 functions in listed trees stay under; never a judgement.
144
+ size: {
145
+ measurement: SIZE.measurement,
146
+ // The listed trees' own heavy shares, so the score can be read from
147
+ // the document alone: `heavyShares[i]` is the i-th listed tree's.
148
+ sample: { trees: SIZE.trees, functions: SIZE.functions, heavyShares: [...SIZE.distribution.heavyShare] },
149
+ thresholds: { lines: SIZE.lines, branches: SIZE.branches, depth: SIZE.depth },
150
+ // The share of this tree's function lines inside functions over the
151
+ // thresholds, and 10 minus its rank among the listed trees' shares:
152
+ // where the tree sits, never whether it is good; null with no
153
+ // function to rank.
154
+ heavyShare: Math.round(heavyShare(functions) * 10000) / 10000,
155
+ score: sizeScore(functions),
156
+ over: overSize(functions),
157
+ },
158
+ // The headline split: a shell script contributes one site per command
159
+ // segment, so a tree with a few scripts carries hundreds of process
160
+ // sites beside a handful of QML Process blocks, and the two are said
161
+ // apart wherever the count is printed.
162
+ counts: {
163
+ processes: {
164
+ total: processes.length,
165
+ qml: processes.filter((row) => row.declaredIn === "qml").length,
166
+ shell: processes.filter((row) => row.declaredIn === "shell").length,
167
+ },
168
+ hosts: hosts.length,
169
+ writes: writes.length,
170
+ timers: timers.length,
171
+ functions: functions.length,
172
+ notResolvable: notResolvable.length,
173
+ },
174
+ notResolvable,
175
+ patterns,
176
+ lookedFor,
177
+ notVisible: [...NOT_VISIBLE],
178
+ marketplaceBaseline,
179
+ }
180
+ }
181
+
182
+ export { PATTERNS }
@@ -0,0 +1,150 @@
1
+ // M12 reproduction: how long a plugin's functions are, in listed trees.
2
+ // The selection rule is the record's: the first 50 distinct community
3
+ // repositories in the pinned catalog's order, laid out as a root plugin and
4
+ // carrying a validated commit, each fetched read-only at that commit in
5
+ // reviewer mode through tools/subject/resolve.mjs and read from the Git
6
+ // object database through walk.mjs. `extractFunctions` runs over every
7
+ // file inspect reads; nothing from a tree is executed. The record carries
8
+ // no repository or commit: the rule reproduces the set.
9
+ //
10
+ // Writes docs/evidence/inspect/<date>-function-lengths.json (or --out FILE)
11
+ // and prints the quantiles to stderr. The three p90 quantiles and the three
12
+ // histograms replace the constants in patterns.mjs SIZE, and the per-tree
13
+ // heavyShare list its SIZE.distribution.heavyShare; tests/unit/submit.test.mjs
14
+ // holds them equal to the record.
15
+
16
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs"
17
+ import { dirname, join, resolve } from "node:path"
18
+ import { pathToFileURL } from "node:url"
19
+ import { requirePin } from "../marketplace/pin.mjs"
20
+ import { CATALOG_PATH } from "../marketplace/registry.mjs"
21
+ import { omakitCacheDir } from "../marketplace/paths.mjs"
22
+ import { resolveSubject } from "../subject/resolve.mjs"
23
+ import { walkSubject } from "./walk.mjs"
24
+ import { extractFunctions } from "./functions.mjs"
25
+
26
+ export const TREES = 50
27
+ const KINDS = ["qml", "js", "shell", "python"]
28
+
29
+ /** The nearest-rank quantile of a sorted list: the value at position ceil(p * n). */
30
+ export function quantile(sorted, p) {
31
+ if (!sorted.length) return null
32
+ return sorted[Math.max(0, Math.ceil(p * sorted.length) - 1)]
33
+ }
34
+
35
+ /** Value to count, keys in ascending numeric order. */
36
+ export function histogram(values) {
37
+ const counts = new Map()
38
+ for (const value of values) counts.set(value, (counts.get(value) || 0) + 1)
39
+ return Object.fromEntries([...counts.entries()].sort((a, b) => a[0] - b[0]).map(([value, count]) => [String(value), count]))
40
+ }
41
+
42
+ /**
43
+ * The record's selection: catalog order, community listings laid out as a
44
+ * root plugin with a validated commit, one entry per repository, the first
45
+ * `count`.
46
+ */
47
+ export function selectTrees(catalog, count = TREES) {
48
+ const seen = new Set()
49
+ const picked = []
50
+ for (const plugin of Array.isArray(catalog.plugins) ? catalog.plugins : []) {
51
+ if (plugin.sourceType !== "community" || plugin.repositoryLayout !== "root-plugin") continue
52
+ if (typeof plugin.repo !== "string" || !/^[0-9a-f]{40}$/i.test(String(plugin.listingValidatedCommit || ""))) continue
53
+ if (seen.has(plugin.repo)) continue
54
+ seen.add(plugin.repo)
55
+ picked.push({ repo: plugin.repo, commit: plugin.listingValidatedCommit.toLowerCase() })
56
+ if (picked.length === count) break
57
+ }
58
+ return picked
59
+ }
60
+
61
+ /** Lines inside functions over any threshold, over lines inside every function; 0 with no function. */
62
+ export function heavyLinesOf(functions, thresholds) {
63
+ return functions.filter((row) => row.lines > thresholds.lines || row.branches > thresholds.branches || row.depth > thresholds.depth).reduce((sum, row) => sum + row.lines, 0)
64
+ }
65
+
66
+ const median = (values) => {
67
+ if (!values.length) return null
68
+ const sorted = [...values].sort((a, b) => a - b)
69
+ const mid = Math.floor(sorted.length / 2)
70
+ return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2
71
+ }
72
+
73
+ /**
74
+ * @param {Array<{ functions: Array, byKind?: object }>} trees the functions of each tree, in selection order
75
+ * @param {{ fetchFailed?: string[], date?: string, previous?: string }} [meta]
76
+ * @returns {object} the record
77
+ */
78
+ export function buildRecord(trees, { fetchFailed = [], date = new Date().toISOString().slice(0, 10), previous = null } = {}) {
79
+ const pooled = trees.flatMap((tree) => tree.functions)
80
+ const sorted = (measure) => pooled.map((row) => row[measure]).sort((a, b) => a - b)
81
+ const lines = sorted("lines")
82
+ const depth = sorted("depth")
83
+ const branches = sorted("branches")
84
+ const quantiles = {
85
+ lines: { p50: quantile(lines, 0.5), p75: quantile(lines, 0.75), p90: quantile(lines, 0.9), p95: quantile(lines, 0.95), max: lines.at(-1) ?? null },
86
+ depth: { p50: quantile(depth, 0.5), p90: quantile(depth, 0.9), max: depth.at(-1) ?? null },
87
+ branches: { p50: quantile(branches, 0.5), p90: quantile(branches, 0.9), max: branches.at(-1) ?? null },
88
+ }
89
+ const thresholds = { lines: quantiles.lines.p90, branches: quantiles.branches.p90, depth: quantiles.depth.p90 }
90
+ const rows = trees.map((tree, index) => {
91
+ const functionLines = tree.functions.reduce((sum, row) => sum + row.lines, 0)
92
+ const heavyLines = heavyLinesOf(tree.functions, thresholds)
93
+ return {
94
+ row: index + 1,
95
+ functions: tree.functions.length,
96
+ byKind: Object.fromEntries(KINDS.map((kind) => [kind, tree.byKind?.[kind] ?? 0])),
97
+ longest: tree.functions.length ? Math.max(...tree.functions.map((row) => row.lines)) : 0,
98
+ medianLines: median(tree.functions.map((row) => row.lines)),
99
+ functionLines,
100
+ heavyLines,
101
+ heavyShare: functionLines ? Math.round((heavyLines / functionLines) * 10000) / 10000 : 0,
102
+ }
103
+ })
104
+ return {
105
+ measurement: "M12",
106
+ date,
107
+ command: "node tools/inspect/measure-functions.mjs",
108
+ method: `extractFunctions from tools/inspect/functions.mjs over the first ${trees.length} distinct repositories in the pinned catalog's order whose listing is community, laid out as a root plugin and carrying a validated commit (the 2026-09-15 inspect record's rule), each fetched read-only at that commit in reviewer mode. A function is a \`function name(\`, a named arrow function (\`const load = (rows) => {\`, \`this.load = rows => {\`), a method shorthand \`load(rows) {\` inside an object literal or a class, or a multi-line \`onSomething: {\` handler in QML and JavaScript; a \`name() {\` or \`function name\` block in shell; a \`def\` in Python. Anonymous callbacks are not counted. Lines are first to last line inclusive; depth is the deepest nesting below the body, the body itself at 0 in every language, a brace that opens an object or array literal or an inline arrow body not a level; branches count if, else if, for, while, switch, case, catch, &&, || and ?: (their shell and Python equivalents). Quantiles are nearest-rank over every function in the sample pooled, not per tree. \`distribution\` is the histogram of each measure over the same functions, value to count, from which a function's percentile rank is read. Per tree, functionLines is the lines inside every function, heavyLines the lines inside functions over any p90 threshold of this record, and heavyShare their ratio, 0 with no function; the size score of omakit inspect is a tree's position among these 50 heavyShares.`,
109
+ sample: { trees: trees.length, functions: pooled.length, fetchFailed },
110
+ quantiles,
111
+ distribution: { lines: histogram(lines), branches: histogram(branches), depth: histogram(depth) },
112
+ rows,
113
+ notes: `Rows carry no repository or commit; the selection rule reproduces the set. A function count and a longest length per tree are counts of what the extraction saw, not a judgement of any plugin.${previous ? ` ${previous}` : ""}`,
114
+ }
115
+ }
116
+
117
+ const PREVIOUS = "Re-measured on 2026-09-16 after three extraction fixes, so the quantiles are not comparable with the 0.4.3 record (this file at commit 6619c26: 50 trees, 6040 functions, p90 22 lines, 6 branches, nesting 3; and before it 18 trees, 715 functions, p90 18 lines, 7 branches, nesting 2): a Python body now starts at depth 0 the way a brace body does, where it started at 1; a brace that opens an object or array literal is no longer a nesting level; and a named arrow function or a method shorthand is a function, where only `function name(` and `onSomething: {` were."
118
+
119
+ export async function measureFunctions(repoRoot, { count = TREES, cacheRoot = omakitCacheDir(), log = () => {} } = {}) {
120
+ const pin = requirePin(repoRoot)
121
+ const catalog = JSON.parse(readFileSync(join(pin.dir, CATALOG_PATH), "utf8"))
122
+ const selected = selectTrees(catalog, count)
123
+ const trees = []
124
+ const fetchFailed = []
125
+ for (const [index, entry] of selected.entries()) {
126
+ log(`${index + 1}/${selected.length} ${entry.repo}@${entry.commit.slice(0, 8)}`)
127
+ let subject
128
+ try {
129
+ subject = resolveSubject(`${entry.repo}@${entry.commit}`, { cacheRoot })
130
+ } catch (error) {
131
+ fetchFailed.push(`row ${index + 1}: ${error.code || "error"}`)
132
+ continue
133
+ }
134
+ const tree = walkSubject(subject)
135
+ const functions = tree.files.flatMap((file) => extractFunctions(file).map((row) => ({ ...row, fileKind: file.kind })))
136
+ const byKind = Object.fromEntries(KINDS.map((kind) => [kind, functions.filter((row) => row.fileKind === kind).length]))
137
+ trees.push({ functions, byKind })
138
+ }
139
+ return buildRecord(trees, { fetchFailed, previous: PREVIOUS })
140
+ }
141
+
142
+ if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
143
+ const repoRoot = resolve(process.cwd())
144
+ const record = await measureFunctions(repoRoot, { log: (text) => process.stderr.write(`${text}\n`) })
145
+ const at = process.argv.indexOf("--out")
146
+ const evidencePath = resolve(at > 0 && process.argv[at + 1] ? process.argv[at + 1] : join(repoRoot, "docs/evidence/inspect", `${record.date}-function-lengths.json`))
147
+ mkdirSync(dirname(evidencePath), { recursive: true })
148
+ writeFileSync(evidencePath, `${JSON.stringify(record, null, 2)}\n`)
149
+ process.stderr.write(`${record.sample.trees} trees, ${record.sample.functions} functions; p90 ${record.quantiles.lines.p90} lines, ${record.quantiles.branches.p90} branches, nesting ${record.quantiles.depth.p90}; ${record.sample.fetchFailed.length} fetch failures\n${evidencePath}\n`)
150
+ }