omakit 0.4.3 → 0.5.1

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 CHANGED
@@ -6,6 +6,20 @@ The marketplace validates one exact commit of your plugin. Push a fix or comment
6
6
 
7
7
  [![Built for Omarchy: App](https://raw.githubusercontent.com/tcballard/omarchy-badges/75975e5b5bf75e7ede3764bcd2950046f7abfe2c/badges/v1/omarchy-app.svg)](https://github.com/tcballard/omarchy-badges) [![npm version](https://img.shields.io/npm/v/omakit)](https://www.npmjs.com/package/omakit) [![CI status](https://img.shields.io/github/actions/workflow/status/mtolhuys/omakit/ci.yml?branch=main)](https://github.com/mtolhuys/omakit/actions/workflows/ci.yml) [![Socket](https://socket.dev/api/badge/npm/package/omakit)](https://socket.dev/npm/package/omakit)
8
8
 
9
+ ## What it delivers
10
+
11
+ | Command | What you get | Read more |
12
+ | --- | --- | --- |
13
+ | `omakit submit <plugin-repo>` | Run the marketplace's own checks before you open the issue, and get the issue text ready to paste. Nothing is posted for you. | [submit](docs/SUBMIT.md) |
14
+ | `omakit inspect <plugin-dir>` | See what needs attention in your plugin before a reviewer does: the longest functions, and the things reviewers flag most often, each with the file and line. | [inspect](docs/INSPECT.md) |
15
+ | `omakit watch <issue-url>` | Know whether the commit the marketplace checked is still the one you are shipping, and what to do when it is not. | [watch](docs/VALIDATION_WATCH.md) |
16
+ | `omakit audit` | Find installed plugins that are running code the marketplace never checked. | [audit](docs/AUDIT.md) |
17
+ | `omakit weigh <plugin>` | Find out what a plugin costs the shell in memory and CPU. | [weigh](docs/WEIGH.md) |
18
+ | `omakit verify <plugin-repo>` | Get the marketplace's security result for your commit, exactly as it would see it. | [commands](docs/COMMANDS.md) |
19
+ | `omakit doctor`, `omakit setup` | Check what is installed and pinned, or set everything up once, with tab completion. | [install](docs/INSTALL.md) |
20
+
21
+ Every number a command prints has a measured origin in [MEASUREMENTS.md](docs/MEASUREMENTS.md); nothing is a guess and nothing is a grade.
22
+
9
23
  ## Install
10
24
 
11
25
  ```bash
@@ -37,9 +51,9 @@ Compares your installed plugin commits with the marketplace's validated commits.
37
51
 
38
52
  ## `omakit inspect <plugin-dir>`
39
53
 
40
- ![inspect showing a size score and the two review classes one fixture shows](https://raw.githubusercontent.com/mtolhuys/omakit/main/docs/media/inspect.gif)
54
+ ![inspect showing a fixture's size score, its two long functions with their ranks, and the one review class it shows](https://raw.githubusercontent.com/mtolhuys/omakit/main/docs/media/inspect.gif)
41
55
 
42
- Reads a plugin's tree and prints what needs attention, biggest first: a size score (10 minus the mean rank of its functions among functions in listed plugins, [M12](docs/MEASUREMENTS.md#m12-how-long-a-plugins-functions-are-in-listed-trees)), the functions over what 90 of 100 listed functions stay under, then each review class the tree shows with the class's measured share of review findings ([M11](docs/MEASUREMENTS.md#m11-what-the-human-review-raises-by-class)) and up to five sites. No verdict, nothing run from the tree; `--full` is every site, `--json` the document. Over 18 listed plugins read at their validated commits, the extraction counted 515 process sites (57 QML `Process` blocks, 458 shell lines), 17 hosts, 63 writes and 40 timers, left 15 rows it could not resolve, and printed 73 pattern rows across 17 of the 18 ([record](docs/evidence/inspect/2026-09-15-listed-sample.json)).
56
+ Reads a plugin's tree and prints what needs attention, biggest first: a size score (the share of its function lines that sit in functions over the measured size, placed among the listed trees' shares, so a tree with no long function scores 10.00, [M12](docs/MEASUREMENTS.md#m12-how-long-a-plugins-functions-are-in-listed-trees)), the functions over what 90 of 100 listed functions stay under, then each review class the tree shows with the class's measured share of review findings ([M11](docs/MEASUREMENTS.md#m11-what-the-human-review-raises-by-class)) and up to five sites. No verdict, nothing run from the tree; `--full` is every site, `--json` the document. Over 18 listed plugins read at their validated commits, the extraction counted 515 process sites (57 QML `Process` blocks, 458 shell lines), 17 hosts, 63 writes and 40 timers, left 15 rows it could not resolve, and printed 73 pattern rows across 17 of the 18 ([record](docs/evidence/inspect/2026-09-15-listed-sample.json)).
43
57
 
44
58
  ## `omakit weigh <plugin>`
45
59
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omakit",
3
- "version": "0.4.3",
3
+ "version": "0.5.1",
4
4
  "description": "The safe place to find out: everything knowable about an Omarchy Quattro plugin submission before you post it, on your own machine. Agent-first, read-only against the marketplace, posts nothing, zero dependencies.",
5
5
  "license": "MIT",
6
6
  "author": "Maarten Tolhuijs",
@@ -53,11 +53,17 @@ share of review findings (`share`, from M11 of `docs/MEASUREMENTS.md`).
53
53
  `size.over` lists the functions longer, more branched or deeper than 90 of
54
54
  100 functions in listed trees (the thresholds are in `size.thresholds`,
55
55
  from M12), longest first, each with its `percentile` among them, and
56
- `size.score` is 10 minus the mean percentile of every function in the
57
- tree. When the owner asks for simpler code, start with the top of
58
- `size.over`, and read the score before and after as the measure of the
59
- change; it is a position among listed plugins, not a grade, so never tell
60
- the owner a score is good or bad, only that it moved. Read
56
+ `size.heavyShare` is the share of the tree's function lines that sit in
57
+ those functions, and `size.score` is 10 minus the share of listed trees
58
+ with a strictly smaller `heavyShare`, divided by 10: 10.00 when no
59
+ function is over a threshold, 0.00 when the tree is heavier than every
60
+ listed one (`size.sample.heavyShares` holds the shares of the listed trees that have a function, 49 of 50). It
61
+ is line-weighted, so splitting a long function into short ones raises it
62
+ and adding small functions beside a long one barely moves it. When the
63
+ owner asks for simpler code, start with the top of `size.over`, and read
64
+ the score before and after as the measure of the change; it is a position
65
+ among listed plugins, not a grade, so never tell the owner a score is good
66
+ or bad, only that it moved. Read
61
67
  the document, not the report: the report a person sees lists at most five
62
68
  sites per class and drops classes under five percent, `--full` prints every
63
69
  site, and `--json` carries all of it either way.
@@ -125,6 +125,7 @@ export function validateInspectDocument(document, known = {}) {
125
125
  const filesRead = subject.filesRead || {}
126
126
  for (const kind of FILE_KINDS) if (!isInt(filesRead[kind]) || filesRead[kind] < 0) problems.push(`subject.filesRead.${kind} is not a count`)
127
127
  for (const key of Object.keys(filesRead)) if (!FILE_KINDS.includes(key)) problems.push(`subject.filesRead.${key} is not a kind inspect reads`)
128
+ if (!isInt(subject.uncommittedFiles) || subject.uncommittedFiles < 0) problems.push("subject.uncommittedFiles is not a count")
128
129
 
129
130
  const observed = document.observed || {}
130
131
  for (const [key, check] of [["processes", processRow], ["hosts", host], ["writes", write], ["timers", timer], ["functions", fn]]) {
@@ -166,15 +167,25 @@ export function validateInspectDocument(document, known = {}) {
166
167
  else {
167
168
  if (!isString(size.measurement) || !/^M\d+$/.test(size.measurement)) problems.push("size.measurement is not a measurement id")
168
169
  for (const key of ["lines", "branches", "depth"]) if (!isInt(size.thresholds?.[key]) || size.thresholds[key] < 1) problems.push(`size.thresholds.${key} is not a count`)
169
- if (!size.sample || !isInt(size.sample.trees) || !isInt(size.sample.functions)) problems.push("size.sample is not { trees, functions }")
170
+ const shares = size.sample?.heavyShares
171
+ if (!size.sample || !isInt(size.sample.trees) || !isInt(size.sample.functions)) problems.push("size.sample is not { trees, functions, heavyShares }")
172
+ else if (!Array.isArray(shares) || !shares.length || shares.length > size.sample.trees || !shares.every((share) => typeof share === "number" && share >= 0 && share <= 1)) problems.push("size.sample.heavyShares is not one share from 0 to 1 per listed tree with a function")
170
173
  const scorable = Array.isArray(observed.functions) && observed.functions.length > 0
174
+ if (typeof size.heavyShare !== "number" || size.heavyShare < 0 || size.heavyShare > 1) problems.push("size.heavyShare is not a share from 0 to 1")
175
+ else if (Array.isArray(observed.functions) && size.thresholds) {
176
+ const total = observed.functions.reduce((sum, row) => sum + row.lines, 0)
177
+ const heavy = observed.functions.filter((row) => row.lines > size.thresholds.lines || row.branches > size.thresholds.branches || row.depth > size.thresholds.depth).reduce((sum, row) => sum + row.lines, 0)
178
+ const expected = total ? heavy / total : 0
179
+ if (Math.abs(expected - size.heavyShare) > 0.00011) problems.push(`size.heavyShare is ${size.heavyShare}; the function lines over the thresholds say ${expected}`)
180
+ }
171
181
  if (size.score === null) {
172
182
  if (scorable) problems.push("size.score is null for a tree with functions")
173
183
  } else if (typeof size.score !== "number" || size.score < 0 || size.score > 10 || Math.abs(Math.round(size.score * 100) - size.score * 100) > 1e-6) problems.push("size.score is not a number from 0 to 10 with two decimals")
174
184
  else if (!scorable) problems.push("size.score is set for a tree with no function")
175
- else {
176
- const expected = Math.round((10 - observed.functions.reduce((sum, row) => sum + row.percentile, 0) / observed.functions.length / 10) * 100) / 100
177
- if (Math.abs(expected - size.score) > 0.011) problems.push(`size.score is ${size.score}; the mean rank of the functions says ${expected}`)
185
+ else if (Array.isArray(shares) && shares.length && typeof size.heavyShare === "number") {
186
+ const rank = (shares.filter((share) => share < size.heavyShare).length / shares.length) * 100
187
+ const expected = Math.round((10 - rank / 10) * 100) / 100
188
+ if (Math.abs(expected - size.score) > 0.011) problems.push(`size.score is ${size.score}; the tree's rank among the listed shares says ${expected}`)
178
189
  }
179
190
  if (!Array.isArray(size.over)) problems.push("size.over is not a list")
180
191
  else {
@@ -1,23 +1,58 @@
1
- // Function sites: every named function, QML handler, shell function and
2
- // Python def, with its length in lines, its deepest nesting and its branch
3
- // count. Size is the one thing a person asks about a tree that regular
4
- // expressions can answer without a parser: where does the logic pile up.
1
+ // Function sites: every named function (declared, assigned as an arrow, or
2
+ // a method), QML handler, shell function and Python def, with its length
3
+ // in lines, its deepest nesting and its branch count. Size is the one
4
+ // thing a person asks about a tree that regular expressions can answer
5
+ // without a parser: where does the logic pile up.
5
6
  // The numbers are counts over the text; the threshold they are compared
6
7
  // with is measured over listed trees (M12), never chosen.
7
8
 
8
9
  import { blankComments, closingBracket, lineOf } from "./text.mjs"
9
10
 
10
11
  const JS_BRANCH = /\b(?:if|else if|for|while|do|switch|case|catch)\b|&&|\|\||\?[^.:]/g
11
- const SHELL_OPEN = /^\s*(?:if|for|while|until|case|select)\b/
12
- const SHELL_CLOSE = /^\s*(?:fi|done|esac)\b/
13
- const SHELL_BRANCH = /\b(?:if|elif|for|while|until|case)\b|\|\||&&|^\s*[^)]*\)\s*(?!\s*$)/
12
+ // A block opens with `if`, `for`, `while`, `until`, `case` or `select` and
13
+ // closes with `fi`, `done` or `esac`, each counted only where a statement
14
+ // can start (the line's start, after `;`, `&`, `|`, `(`, `then`, `do` or
15
+ // `else`), over the line with its quoted text removed, so `if x; then y;
16
+ // fi` on one line nets zero and is not a level, `x && if y; then z; fi`
17
+ // nets zero too, and neither `echo "done"` nor `echo done` closes anything.
18
+ const SHELL_OPEN = /(?:^|[;&|(]|\b(?:then|do|else))\s*(?:if|for|while|until|case|select)\b/g
19
+ const SHELL_CLOSE = /(?:^|[;&|(]|\b(?:then|do|else))\s*(?:fi|done|esac)\b/g
20
+ // A case arm, `pattern) command` or `(pattern) command`, is a branch: a `)`
21
+ // with text after it on a line with no `(` before it but an opening one,
22
+ // so a `$(...)` or `(( ))` in a test is not one.
23
+ const SHELL_BRANCH = /\b(?:if|elif|for|while|until|case)\b|\|\||&&|^\s*\(?[^()]*\)\s*(?!\s*$)/
24
+ // A guard, not a branch: `||` or `&&` followed by one flow word (`return`,
25
+ // `exit`, `continue`, `break`, `true`, `false`, `:`) with an optional
26
+ // status (a number, `$?` or a variable), then nothing but a `;` or the `;;`
27
+ // that ends a case arm, as in `[[ -f $x ]] || return 1`. One guard per
28
+ // line, the one at its end: `x && return 0 || return 1` is a choice, and
29
+ // its `&&` counts. JavaScript has no such idiom, so shell alone is
30
+ // exempted.
31
+ // Matched against the trimmed end of the code, and anchored there, so a long run of spaces costs nothing.
32
+ const SHELL_GUARD = /(?:\|\||&&)\s*(?:return|exit|continue|break|true|false|:)(?:\s+(?:\$\?|\$\{?\w+\}?|\d+))?\s*;{0,2}$/
33
+ // `<<WORD`, `<<-WORD`, `<<'WORD'`, `<<"WORD"` or `<<\WORD` outside quotes and
34
+ // outside `(( ))`; `<<<` is a here-string and `<<` in arithmetic a shift.
35
+ const HEREDOC = /^<<(-?)\s*(?:(['"])([A-Za-z_][\w.-]*)\2|\\?([A-Za-z_][\w.-]*))/
36
+ // The line that closes a shell function: `}` alone, or `}` with a comment or a redirection after it.
37
+ const SHELL_END = /^\}\s*(?:#.*|[<>&|].*)?$/
14
38
  const PY_BRANCH = /^\s*(?:if|elif|for|while|except|with)\b|\band\b|\bor\b/
15
39
 
16
- /** Deepest brace nesting inside a body, relative to the body itself. */
40
+ /**
41
+ * Deepest brace nesting inside a body, relative to the body itself. A `{`
42
+ * that opens a literal is not a level: an object or array literal spread
43
+ * over lines (`return {`, `foo({`, `x = {`, `[{`, `? {`, `a || {`) and an
44
+ * inline arrow body (`=> {`) are values, not control flow. The test is the
45
+ * last non-space text before the brace, so this is a regular-expression
46
+ * heuristic, not a parser: it aims at an object literal not counting as
47
+ * nesting, and a `{` after `)` or `else` or on a line of its own counts.
48
+ */
49
+ const LITERAL_BEFORE = /(?:[(,:=[?]|\breturn|=>|\|\||&&)\s*$/
50
+
17
51
  function braceDepth(body) {
18
52
  let depth = 0
19
53
  let deepest = 0
20
54
  let quote = null
55
+ const literal = []
21
56
  for (let i = 0; i < body.length; i += 1) {
22
57
  const ch = body[i]
23
58
  if (quote) {
@@ -27,30 +62,50 @@ function braceDepth(body) {
27
62
  }
28
63
  if (ch === '"' || ch === "'" || ch === "`") quote = ch
29
64
  else if (ch === "{") {
65
+ const isLiteral = LITERAL_BEFORE.test(body.slice(Math.max(0, i - 12), i))
66
+ literal.push(isLiteral)
67
+ if (isLiteral) continue
30
68
  depth += 1
31
69
  if (depth > deepest) deepest = depth
32
- } else if (ch === "}") depth -= 1
70
+ } else if (ch === "}") {
71
+ if (!literal.pop()) depth -= 1
72
+ }
33
73
  }
34
74
  return deepest
35
75
  }
36
76
 
77
+ /** Words that stand before `(...) {` without naming a method. */
78
+ const NOT_A_METHOD = new Set(["if", "for", "while", "switch", "catch", "with", "function", "return", "else", "do", "try"])
79
+
37
80
  function jsFunctions(file) {
38
81
  const text = blankComments(file.text)
39
82
  const rows = []
40
- // `function name(` and, in QML, `onSomething: {` handlers with a body block.
41
- const pattern = /(?<![\w.])function\s+([A-Za-z_$][\w$]*)\s*\(|(?<![\w.])(on[A-Z]\w*)\s*:\s*(?=\{)/g
83
+ // Four shapes, each followed by a body block: `function name(`; in QML,
84
+ // `onSomething: {` handlers; a named arrow function, `const load = (rows)
85
+ // => {` or `this.load = rows => {` on a property; and method shorthand,
86
+ // `load(rows) {` at the start of a line inside an object literal or a
87
+ // class. An anonymous callback (`(x) => {`, `function (x) {`) has no name
88
+ // and is not counted; the method shape excludes the keywords that stand
89
+ // before `(...) {` (`if`, `for`, `while`, `switch`, `catch`).
90
+ const pattern = new RegExp([
91
+ /(?<![\w.])function\s+([A-Za-z_$][\w$]*)\s*\(/.source,
92
+ /(?<![\w.])(on[A-Z]\w*)\s*:\s*(?=\{)/.source,
93
+ /(?<![\w$])([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:\([^()\n]*\)|[A-Za-z_$][\w$]*)\s*=>[ \t]*(?=\{)/.source,
94
+ /^[ \t]*(?:(?:async|static)\s+)*([A-Za-z_$][\w$]*)[ \t]*\([^()\n]*\)[ \t]*(?=\{)/.source,
95
+ ].join("|"), "gm")
42
96
  for (const match of text.matchAll(pattern)) {
43
- const open = text.indexOf("{", match.index + match[0].length - (match[2] ? 0 : 0))
97
+ if (match[4] && NOT_A_METHOD.has(match[4])) continue
98
+ const open = text.indexOf("{", match.index + match[0].length)
44
99
  if (open < 0) continue
45
100
  const end = closingBracket(text, open)
46
101
  if (end < 0) continue
47
102
  const body = text.slice(open + 1, end)
48
- const start = lineOf(text, match.index)
103
+ const start = lineOf(text, match.index + match[0].length - match[0].trimStart().length)
49
104
  rows.push({
50
105
  file: file.path,
51
106
  line: start,
52
- name: match[1] || match[2],
53
- kind: match[1] ? "function" : "handler",
107
+ name: match[1] || match[2] || match[3] || match[4],
108
+ kind: match[2] ? "handler" : "function",
54
109
  lines: lineOf(text, end) - start + 1,
55
110
  depth: braceDepth(body),
56
111
  branches: (body.match(JS_BRANCH) || []).length,
@@ -59,6 +114,105 @@ function jsFunctions(file) {
59
114
  return rows
60
115
  }
61
116
 
117
+ /**
118
+ * One shell line read left to right with a stack of contexts: a
119
+ * single-quoted string, a double-quoted string, an ANSI-C `$'...'` string,
120
+ * and, nested in a double-quoted string, `$(...)`, `${...}` and a
121
+ * backtick substitution, which is what lets `"$(printf "it's")"` and
122
+ * `"${x:-"it's"}"` read their inner quotes as their own. It returns the
123
+ * code before a `#` that starts a comment, the heredoc the line opens, and
124
+ * the contexts left open at its end. Quoted text is data and left out of
125
+ * the code, whether the quote closes on the line or spans lines (the
126
+ * heredoc delimiter is read here, before the quotes go, so `<<'PY'` and
127
+ * `<<PY` read alike), while what a substitution holds is shell and kept. A `'` inside double quotes
128
+ * ("Okomart's") and a `#` inside quotes (`*'#'*`) are text; a backslash
129
+ * escapes in code, inside double quotes and inside `$'...'`, and inside
130
+ * plain single quotes nothing does. `<<` in shell, at the top or inside a
131
+ * substitution, and outside `(( ))`, is a heredoc.
132
+ * @param {string} line
133
+ * @param {Array<{ kind: string, depth: number }>} open the contexts open from the line above, innermost last
134
+ * @returns {{ code: string, open: Array<{ kind: string, depth: number }>, heredoc: { word: string, strip: boolean } | null }}
135
+ */
136
+ function scanShellLine(line, open) {
137
+ const stack = open.map((entry) => ({ ...entry }))
138
+ let code = ""
139
+ let heredoc = null
140
+ // Depth of `((` arithmetic on this line, inside which `<<` is a shift.
141
+ let arith = 0
142
+ const top = () => stack[stack.length - 1] || null
143
+ const isCode = (kind) => kind === "code" || kind === "$(" || kind === "${" || kind === "`"
144
+ // Text inside a string is data; text in shell, nested or not, is kept.
145
+ const keep = () => {
146
+ const inner = top()
147
+ return !inner || isCode(inner.kind)
148
+ }
149
+ for (let i = 0; i < line.length; i += 1) {
150
+ const ch = line[i]
151
+ const context = top()
152
+ const kind = context ? context.kind : "code"
153
+ if (kind === "'") {
154
+ if (ch === "'") stack.pop()
155
+ else if (keep()) code += ch
156
+ continue
157
+ }
158
+ if (ch === "\\") {
159
+ i += 1
160
+ if (keep()) code += ch + (line[i] ?? "")
161
+ continue
162
+ }
163
+ if (kind === '"' || kind === "$'") {
164
+ if (ch === kind[kind.length - 1]) stack.pop()
165
+ else if (kind === '"' && ch === "$" && (line[i + 1] === "(" || line[i + 1] === "{")) {
166
+ stack.push({ kind: `$${line[i + 1]}`, depth: 0 })
167
+ code += `$${line[i + 1]}`
168
+ i += 1
169
+ // `$((` under a quote is arithmetic: its `<<` is a shift.
170
+ if (line[i] === "(" && line[i + 1] === "(") arith += 1
171
+ } else if (kind === '"' && ch === "`") {
172
+ stack.push({ kind: "`", depth: 0 })
173
+ code += ch
174
+ } else if (keep()) code += ch
175
+ continue
176
+ }
177
+ // Shell code: at the top, or inside a substitution under a double quote.
178
+ if (kind === "$(" || kind === "${") {
179
+ const [opener, closer] = kind === "$(" ? ["(", ")"] : ["{", "}"]
180
+ if (ch === opener) context.depth += 1
181
+ else if (ch === closer) {
182
+ if (context.depth === 0) {
183
+ stack.pop()
184
+ code += ch
185
+ continue
186
+ }
187
+ context.depth -= 1
188
+ }
189
+ } else if (kind === "`" && ch === "`") {
190
+ stack.pop()
191
+ code += ch
192
+ continue
193
+ }
194
+ if (ch === "#" && (i === 0 || /[\s;()&|]/.test(line[i - 1]))) break
195
+ if (ch === "'" || ch === '"') {
196
+ stack.push({ kind: ch === "'" && line[i - 1] === "$" ? "$'" : ch, depth: 0 })
197
+ continue
198
+ }
199
+ if (ch === "(" && line[i + 1] === "(") arith += 1
200
+ else if (ch === ")" && line[i + 1] === ")" && arith) arith -= 1
201
+ if (ch === "<" && line[i + 1] === "<" && line[i - 1] !== "<" && line[i + 2] !== "<" && !heredoc && !arith) {
202
+ const found = line.slice(i).match(HEREDOC)
203
+ if (found) heredoc = { word: found[3] || found[4], strip: found[1] === "-" }
204
+ }
205
+ code += ch
206
+ }
207
+ return { code, open: stack.map(({ kind, depth }) => ({ kind, depth })), heredoc }
208
+ }
209
+
210
+ /** The code with any guard tail removed, so what is left is what SHELL_BRANCH reads. */
211
+ function withoutGuards(code) {
212
+ // The one guard at the end: `a || b && return` is one guard over a real `||`.
213
+ return code.trimEnd().replace(SHELL_GUARD, "").trimEnd()
214
+ }
215
+
62
216
  function shellFunctions(file) {
63
217
  const lines = file.text.split("\n")
64
218
  const rows = []
@@ -72,18 +226,37 @@ function shellFunctions(file) {
72
226
  let depth = 0
73
227
  let deepest = 0
74
228
  let branches = 0
229
+ // Data inside the function is not shell: the body of a heredoc up to its
230
+ // delimiter alone on a line (leading tabs allowed after `<<-`), and
231
+ // quoted text, on one line or spanning lines (an awk or python program
232
+ // in single quotes, a remote command in double quotes). Those count
233
+ // toward the length and toward nothing else; the shell around them,
234
+ // and inside a substitution nested in them, is read. Two heredocs on
235
+ // one line: the first is tracked, the second's body is read as shell.
236
+ let heredoc = null
237
+ let open = []
75
238
  for (let at = index + 1; at < lines.length; at += 1) {
76
239
  const line = lines[at]
77
- if (line.trim() === "}" && line.startsWith(indent) && line.match(/^\s*/)[0].length === indent.length) {
240
+ if (heredoc) {
241
+ if ((heredoc.strip ? line.replace(/^\t+/, "") : line) === heredoc.word) heredoc = null
78
242
  end = at
79
- break
243
+ continue
80
244
  }
81
- if (SHELL_OPEN.test(line)) {
82
- depth += 1
83
- if (depth > deepest) deepest = depth
245
+ if (!open.length && SHELL_END.test(line.trim()) && line.startsWith(indent) && line.match(/^\s*/)[0].length === indent.length) {
246
+ end = at
247
+ break
84
248
  }
85
- if (SHELL_CLOSE.test(line)) depth -= 1
86
- if (SHELL_BRANCH.test(line.split("#")[0])) branches += 1
249
+ const scanned = scanShellLine(line, open)
250
+ const code = withoutGuards(scanned.code)
251
+ open = scanned.open
252
+ heredoc = scanned.heredoc
253
+ // A line that opens inside a string is read only after the string closes: no `if` at its start, only what the code holds.
254
+ // The line's net over its code: `if x; then y; fi` on one line is no level.
255
+ const net = (code.match(SHELL_OPEN) || []).length - (code.match(SHELL_CLOSE) || []).length
256
+ // Never below the body: a close the scanner misread cannot hide every later level.
257
+ depth = Math.max(0, depth + net)
258
+ if (depth > deepest) deepest = depth
259
+ if (SHELL_BRANCH.test(code)) branches += 1
87
260
  end = at
88
261
  }
89
262
  rows.push({ file: file.path, line: index + 1, name, kind: "function", lines: end - index + 1, depth: deepest, branches })
@@ -92,24 +265,117 @@ function shellFunctions(file) {
92
265
  return rows
93
266
  }
94
267
 
268
+ /**
269
+ * Opening brackets minus closing ones on a line, outside string literals
270
+ * and comments, carrying the state of a triple-quoted string across lines
271
+ * so a bracket inside a docstring or an SQL text counts nothing; and the
272
+ * code of the line with its strings and comment removed, so `and`, `or`
273
+ * and `if` in prose are not branches.
274
+ * @param {string} line
275
+ * @param {string|null} triple the triple quote open from the line above, or null
276
+ * @returns {{ balance: number, triple: string|null, continued: boolean, code: string }} `continued` when the line ends in a backslash outside a string
277
+ */
278
+ function bracketBalance(line, triple) {
279
+ let balance = 0
280
+ let quote = triple
281
+ let code = ""
282
+ let i = 0
283
+ for (; i < line.length; i += 1) {
284
+ const ch = line[i]
285
+ if (quote) {
286
+ if (ch === "\\") i += 1
287
+ else if (quote.length === 3 ? line.startsWith(quote, i) : ch === quote) {
288
+ i += quote.length - 1
289
+ quote = null
290
+ }
291
+ continue
292
+ }
293
+ if (ch === "#") break
294
+ if (ch === '"' || ch === "'") {
295
+ quote = line.startsWith(ch.repeat(3), i) ? ch.repeat(3) : ch
296
+ i += quote.length - 1
297
+ continue
298
+ }
299
+ if (ch === "(" || ch === "[" || ch === "{") balance += 1
300
+ else if (ch === ")" || ch === "]" || ch === "}") balance -= 1
301
+ code += ch
302
+ }
303
+ // A single quote never spans a line; a triple one does.
304
+ const open = quote && quote.length === 3 ? quote : null
305
+ return { balance, triple: open, continued: !open && /\\$/.test(code.trimEnd()), code }
306
+ }
307
+
308
+ const PY_DEF = /^(\s*)(?:async\s+)?def\s+([A-Za-z_]\w*)\s*\(/
309
+ const PY_CLOSER = /^\s*[)\]}]/
310
+
95
311
  function pythonFunctions(file) {
96
312
  const lines = file.text.split("\n")
97
313
  const rows = []
314
+ // Whether each line starts inside a triple-quoted string, over the whole
315
+ // file, so a `def` quoted in a docstring's example is not a function.
316
+ const quoted = new Array(lines.length)
317
+ let moduleTriple = null
318
+ for (let at = 0; at < lines.length; at += 1) {
319
+ quoted[at] = moduleTriple !== null
320
+ moduleTriple = bracketBalance(lines[at], moduleTriple).triple
321
+ }
98
322
  for (let index = 0; index < lines.length; index += 1) {
99
- const head = lines[index].match(/^(\s*)(?:async\s+)?def\s+([A-Za-z_]\w*)\s*\(/)
323
+ if (quoted[index]) continue
324
+ const head = lines[index].match(PY_DEF)
100
325
  if (!head) continue
101
326
  const base = head[1].length
102
327
  let end = index
103
328
  let deepest = 0
104
329
  let branches = 0
330
+ // Depth is relative to the body, the way it is for a brace block: the
331
+ // body's own indent is depth 0 and one `if` is depth 1. The unit is
332
+ // one indentation step, read from the first body line (4 spaces by
333
+ // PEP 8, 2 in some trees); 4 is assumed when there is no body line.
334
+ let unit = 4
335
+ let first = true
336
+ // A line that starts while a bracket is open, inside a triple-quoted
337
+ // string, or after a line ending in a backslash is a continuation of
338
+ // the statement above it (the arguments of a multi-line call, a
339
+ // docstring, a split condition): it counts toward the length and the
340
+ // branches, never toward depth, and never sets the unit. The def's own
341
+ // parameter list, when it spans lines, is a continuation of the def.
342
+ // The balance is over `(`, `[` and `{` minus their closers, outside
343
+ // string literals, the way braceDepth skips quotes. A miscount cannot
344
+ // run past the function: a bracket or backslash continuation ends at
345
+ // the first line at the def's indent or shallower that is not a
346
+ // closing bracket, whatever the balance says; a triple-quoted string
347
+ // runs to its close, since an SQL or help text inside it may sit at
348
+ // column 0.
349
+ let state = bracketBalance(lines[index], null)
350
+ let balance = Math.max(0, state.balance)
351
+ let triple = state.triple
352
+ let continued = state.continued
105
353
  for (let at = index + 1; at < lines.length; at += 1) {
106
354
  const line = lines[at]
107
355
  if (!line.trim()) continue
108
356
  const indent = line.match(/^\s*/)[0].length
357
+ const continuation = balance > 0 || triple !== null || continued
358
+ if (continuation && triple === null && indent <= base && !PY_CLOSER.test(line)) break
359
+ state = bracketBalance(line, triple)
360
+ if (continuation) {
361
+ balance = Math.max(0, balance + state.balance)
362
+ triple = state.triple
363
+ continued = state.continued
364
+ if (PY_BRANCH.test(state.code)) branches += 1
365
+ end = at
366
+ continue
367
+ }
109
368
  if (indent <= base) break
110
- const level = Math.floor((indent - base) / 4)
369
+ balance = Math.max(0, state.balance)
370
+ triple = state.triple
371
+ continued = state.continued
372
+ if (first) {
373
+ unit = indent - base
374
+ first = false
375
+ }
376
+ const level = Math.max(0, Math.floor((indent - base) / unit) - 1)
111
377
  if (level > deepest) deepest = level
112
- if (PY_BRANCH.test(line)) branches += 1
378
+ if (PY_BRANCH.test(state.code)) branches += 1
113
379
  end = at
114
380
  }
115
381
  rows.push({ file: file.path, line: index + 1, name: head[2], kind: "function", lines: end - index + 1, depth: deepest, branches })
@@ -3,7 +3,8 @@
3
3
  // the commit, run the four extractors over every file inspect reads, run
4
4
  // the marketplace's own baseline through `verify` for the capabilities,
5
5
  // and build the document of docs/INSPECT.md. No verdict, and the one score
6
- // a position among listed plugins, never a grade: every
6
+ // a position among listed trees by how much of its function text is in
7
+ // long functions, never a grade: every
7
8
  // row is a fact the text shows, labelled observed, and the document ends
8
9
  // with what the method cannot see.
9
10
  //
@@ -23,7 +24,7 @@ import { extractHosts } from "./hosts.mjs"
23
24
  import { extractWrites } from "./writes.mjs"
24
25
  import { extractTimers } from "./timers.mjs"
25
26
  import { extractFunctions } from "./functions.mjs"
26
- import { evaluatePatterns, overSize, PATTERNS, rankOf, SIZE, sizeScore } from "./patterns.mjs"
27
+ import { evaluatePatterns, heavyShare, overSize, PATTERNS, rankOf, SIZE, sizeScore } from "./patterns.mjs"
27
28
 
28
29
  export const METHOD = "static extraction, regular expressions over qml and shell; observed, not executed"
29
30
 
@@ -62,6 +63,9 @@ export async function inspectPlugin({ repoRoot, target, offline = false, allowDi
62
63
  try {
63
64
  subject = resolveSubject(target, { cacheRoot, allowDirty })
64
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
+ }
65
69
  if (error instanceof SubjectError) throw new InspectError(error.code, error.message)
66
70
  throw error
67
71
  }
@@ -128,6 +132,10 @@ export async function inspectPlugin({ repoRoot, target, offline = false, allowDi
128
132
  mode: subject.mode,
129
133
  pluginId,
130
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,
131
139
  },
132
140
  observed: { processes, hosts, writes, timers, functions },
133
141
  // Size: the functions over the M12 thresholds, longest first. A count of
@@ -135,11 +143,16 @@ export async function inspectPlugin({ repoRoot, target, offline = false, allowDi
135
143
  // 100 functions in listed trees stay under; never a judgement.
136
144
  size: {
137
145
  measurement: SIZE.measurement,
138
- sample: { trees: SIZE.trees, functions: SIZE.functions },
146
+ // The listed trees' own heavy shares, in the record's row order and
147
+ // only for the trees with a function, so the score can be read from
148
+ // the document alone.
149
+ sample: { trees: SIZE.trees, functions: SIZE.functions, heavyShares: [...SIZE.distribution.heavyShare] },
139
150
  thresholds: { lines: SIZE.lines, branches: SIZE.branches, depth: SIZE.depth },
140
- // 10 minus the mean rank of this tree's functions among the listed
141
- // ones: where the tree sits, never whether it is good; null with no
151
+ // The share of this tree's function lines inside functions over the
152
+ // thresholds, and 10 minus its rank among the listed trees' shares:
153
+ // where the tree sits, never whether it is good; null with no
142
154
  // function to rank.
155
+ heavyShare: Math.round(heavyShare(functions) * 10000) / 10000,
143
156
  score: sizeScore(functions),
144
157
  over: overSize(functions),
145
158
  },
@@ -0,0 +1,159 @@
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
+ // null, not 0, with no function: a tree with nothing to measure did not measure light.
102
+ heavyShare: functionLines ? Math.round((heavyLines / functionLines) * 10000) / 10000 : null,
103
+ }
104
+ })
105
+ return {
106
+ measurement: "M12",
107
+ date,
108
+ command: "node tools/inspect/measure-functions.mjs",
109
+ method: [
110
+ `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.`,
111
+ "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.",
112
+ "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).",
113
+ "In shell, a `||` or `&&` followed by one flow word (return, exit, continue, break, true, false, :) with an optional status (a number, `$?` or a variable) and nothing else on the line but a `;` or `;;` is a guard and not a branch, one per line at its end, so `x && return 0 || return 1` keeps its `&&`; the body of a heredoc (`<<WORD`, `<<-WORD`, quoted or backslashed, outside quotes and outside arithmetic) up to its delimiter alone on a line, and a quoted string that spans lines from its opening quote to the line that closes it, count toward the length and toward nothing else, while a `$(...)`, `${...}` or backtick substitution nested in a string reads its own quotes and, spanning lines, is shell and read as shell; a case arm is a `)` with text after it on a line with no `(` before it but its own opening one; a block opens with `if`, `for`, `while`, `until`, `case` or `select` and closes with `fi`, `done` or `esac`, each counted only where a statement can start over the line with its quoted text removed, so a block that opens and closes on one line is no level and a keyword inside a string or as an argument is nothing.",
114
+ "In Python, a line that starts while a bracket is open, inside a triple-quoted string, or after a line ending in a backslash is a continuation of the statement above it, and a def's own parameter list spanning lines is a continuation of the def: it counts toward the length and the branches and never toward nesting; a bracket or backslash continuation ends at the first line at the def's indent that is not a closing bracket, a triple-quoted string at its close. Branch words inside strings, docstrings and comments are prose, not branches, and a def quoted in a docstring is not a function.",
115
+ "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.",
116
+ "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, null with no function; the size score of omakit inspect is a tree's position among the non-null heavyShares.",
117
+ ].join(" "),
118
+ sample: { trees: trees.length, functions: pooled.length, fetchFailed },
119
+ quantiles,
120
+ distribution: { lines: histogram(lines), branches: histogram(branches), depth: histogram(depth) },
121
+ rows,
122
+ 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}` : ""}`,
123
+ }
124
+ }
125
+
126
+ 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. Re-measured again for 0.5.1 after more (the 0.5.0 record is this file at commit 7df451a: 6041 functions, p90 22 lines, 6 branches, nesting 2, six heavyShares of 0 among 50): a Python continuation line inside an open bracket, a triple-quoted string or after a backslash no longer counts as nesting, and a def whose parameter list spans lines is read through to its body where before only the signature was, which is what moved the line p90 from 22 to 25; branch words in Python strings and docstrings are prose; a shell heredoc body and a quoted program spanning lines are no longer read as shell; a shell guard (`|| return 1`) is no longer a branch; and a tree with no function carries heavyShare null instead of 0, so it no longer lifts every other tree's rank. The earlier 0.5.1 cuts, never released, are in this file's history, each corrected by the next: one read a `\"` inside a string and a `\"\"\"` inside a bracket wrongly and stretched two listed Python functions (101 to 133 and 128 to 162 lines); one still read `<<` in arithmetic and a quote inside `$(...)` inside a string wrongly, and counted docstring prose as branches; one counted `if x; then y; fi` on one line as a nesting level; and one closed a block on `done` as a plain argument and peeled two guards from one line."
127
+
128
+ export async function measureFunctions(repoRoot, { count = TREES, cacheRoot = omakitCacheDir(), log = () => {} } = {}) {
129
+ const pin = requirePin(repoRoot)
130
+ const catalog = JSON.parse(readFileSync(join(pin.dir, CATALOG_PATH), "utf8"))
131
+ const selected = selectTrees(catalog, count)
132
+ const trees = []
133
+ const fetchFailed = []
134
+ for (const [index, entry] of selected.entries()) {
135
+ log(`${index + 1}/${selected.length} ${entry.repo}@${entry.commit.slice(0, 8)}`)
136
+ let subject
137
+ try {
138
+ subject = resolveSubject(`${entry.repo}@${entry.commit}`, { cacheRoot })
139
+ } catch (error) {
140
+ fetchFailed.push(`row ${index + 1}: ${error.code || "error"}`)
141
+ continue
142
+ }
143
+ const tree = walkSubject(subject)
144
+ const functions = tree.files.flatMap((file) => extractFunctions(file).map((row) => ({ ...row, fileKind: file.kind })))
145
+ const byKind = Object.fromEntries(KINDS.map((kind) => [kind, functions.filter((row) => row.fileKind === kind).length]))
146
+ trees.push({ functions, byKind })
147
+ }
148
+ return buildRecord(trees, { fetchFailed, previous: PREVIOUS })
149
+ }
150
+
151
+ if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
152
+ const repoRoot = resolve(process.cwd())
153
+ const record = await measureFunctions(repoRoot, { log: (text) => process.stderr.write(`${text}\n`) })
154
+ const at = process.argv.indexOf("--out")
155
+ const evidencePath = resolve(at > 0 && process.argv[at + 1] ? process.argv[at + 1] : join(repoRoot, "docs/evidence/inspect", `${record.date}-function-lengths.json`))
156
+ mkdirSync(dirname(evidencePath), { recursive: true })
157
+ writeFileSync(evidencePath, `${JSON.stringify(record, null, 2)}\n`)
158
+ 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`)
159
+ }
@@ -89,18 +89,23 @@ function secretLogs(files) {
89
89
  */
90
90
  export const SIZE = Object.freeze({
91
91
  measurement: "M12",
92
- sample: "50 listed trees, 6040 functions",
92
+ sample: "50 listed trees, 6034 functions",
93
93
  trees: 50,
94
- functions: 6040,
95
- lines: 22,
94
+ functions: 6034,
95
+ lines: 25,
96
96
  branches: 6,
97
- depth: 3,
98
- // The histogram of each measure over the 6040 functions, value to count,
99
- // from which a function's percentile rank among listed functions is read.
97
+ depth: 2,
98
+ // The histogram of each measure over the 6034 functions, value to count,
99
+ // from which a function's percentile rank among listed functions is read;
100
+ // and each listed tree's heavyShare, the share of its function lines in
101
+ // functions over the thresholds, in the record's row order and only for
102
+ // the trees with a function (a tree with nothing to measure has no
103
+ // share), from which a tree's position among listed trees is read.
100
104
  distribution: Object.freeze({
101
- lines: Object.freeze({ 1: 330, 2: 185, 3: 682, 4: 659, 5: 629, 6: 514, 7: 420, 8: 339, 9: 301, 10: 235, 11: 192, 12: 138, 13: 150, 14: 129, 15: 104, 16: 75, 17: 84, 18: 64, 19: 70, 20: 48, 21: 51, 22: 46, 23: 45, 24: 42, 25: 27, 26: 25, 27: 16, 28: 24, 29: 24, 30: 17, 31: 18, 32: 23, 33: 18, 34: 19, 35: 10, 36: 5, 37: 11, 38: 12, 39: 12, 40: 9, 41: 10, 42: 12, 43: 9, 44: 8, 45: 7, 46: 14, 47: 8, 48: 11, 49: 8, 50: 6, 51: 2, 52: 2, 53: 6, 54: 2, 55: 7, 56: 4, 57: 3, 58: 5, 60: 6, 61: 2, 62: 3, 63: 6, 64: 1, 65: 3, 66: 1, 67: 3, 68: 4, 69: 8, 70: 5, 71: 6, 72: 2, 73: 2, 74: 1, 75: 1, 76: 1, 77: 1, 78: 1, 79: 3, 80: 1, 81: 3, 82: 1, 83: 1, 85: 1, 87: 1, 89: 1, 90: 2, 91: 2, 92: 1, 94: 1, 96: 2, 97: 2, 98: 1, 99: 1, 100: 1, 101: 1, 102: 2, 104: 1, 108: 1, 110: 2, 111: 1, 112: 1, 113: 1, 115: 2, 116: 1, 117: 1, 119: 1, 120: 1, 121: 1, 128: 2, 129: 1, 133: 1, 134: 1, 144: 1, 145: 1, 154: 1, 161: 1, 173: 1, 253: 1, 277: 1, 292: 1, 365: 1, 495: 1 }),
102
- branches: Object.freeze({ 0: 2336, 1: 1096, 2: 807, 3: 454, 4: 339, 5: 233, 6: 171, 7: 125, 8: 90, 9: 68, 10: 47, 11: 53, 12: 24, 13: 26, 14: 18, 15: 11, 16: 17, 17: 18, 18: 12, 19: 12, 20: 11, 21: 3, 22: 5, 23: 7, 24: 8, 25: 4, 26: 1, 27: 6, 28: 4, 29: 5, 31: 1, 32: 3, 33: 3, 34: 1, 35: 1, 36: 4, 37: 1, 38: 1, 40: 1, 41: 1, 43: 2, 44: 1, 45: 2, 46: 1, 64: 1, 75: 1, 78: 1, 85: 1, 87: 2 }),
103
- depth: Object.freeze({ 0: 2606, 1: 1938, 2: 859, 3: 327, 4: 133, 5: 71, 6: 60, 7: 26, 8: 8, 9: 6, 10: 2, 13: 2, 15: 1, 18: 1 }),
105
+ lines: Object.freeze({ 1: 330, 2: 157, 3: 667, 4: 620, 5: 584, 6: 508, 7: 413, 8: 337, 9: 301, 10: 238, 11: 195, 12: 138, 13: 152, 14: 131, 15: 106, 16: 75, 17: 86, 18: 62, 19: 71, 20: 57, 21: 51, 22: 56, 23: 47, 24: 44, 25: 33, 26: 28, 27: 24, 28: 26, 29: 27, 30: 17, 31: 21, 32: 22, 33: 18, 34: 23, 35: 11, 36: 5, 37: 16, 38: 13, 39: 13, 40: 9, 41: 13, 42: 12, 43: 11, 44: 9, 45: 8, 46: 14, 47: 8, 48: 11, 49: 7, 50: 6, 51: 1, 52: 2, 53: 6, 54: 3, 55: 7, 56: 4, 57: 3, 58: 5, 59: 3, 60: 8, 61: 5, 62: 3, 63: 6, 64: 2, 65: 3, 66: 1, 67: 3, 68: 4, 69: 9, 70: 7, 71: 7, 72: 3, 73: 3, 74: 3, 75: 1, 76: 1, 77: 2, 78: 2, 79: 3, 80: 3, 81: 4, 82: 3, 83: 1, 85: 1, 86: 3, 87: 3, 88: 1, 89: 1, 90: 3, 91: 2, 92: 2, 93: 2, 94: 1, 96: 3, 97: 2, 98: 1, 99: 1, 100: 1, 101: 3, 102: 2, 104: 2, 107: 2, 108: 1, 109: 2, 110: 2, 111: 2, 112: 2, 113: 2, 115: 3, 116: 1, 117: 2, 119: 1, 120: 1, 121: 1, 125: 1, 126: 1, 128: 2, 129: 1, 133: 1, 134: 1, 142: 1, 144: 1, 145: 1, 154: 1, 155: 1, 161: 2, 173: 1, 217: 2, 253: 1, 272: 1, 277: 1, 290: 1, 292: 1, 298: 1, 325: 1, 365: 1, 495: 1 }),
106
+ branches: Object.freeze({ 0: 2302, 1: 1130, 2: 828, 3: 457, 4: 344, 5: 221, 6: 180, 7: 127, 8: 83, 9: 70, 10: 48, 11: 48, 12: 24, 13: 30, 14: 20, 15: 10, 16: 14, 17: 10, 18: 12, 19: 8, 20: 6, 21: 4, 22: 5, 23: 5, 24: 7, 25: 4, 26: 2, 27: 7, 28: 5, 29: 2, 31: 1, 32: 2, 33: 2, 35: 1, 36: 3, 38: 1, 39: 1, 40: 1, 41: 1, 43: 1, 45: 2, 55: 1, 64: 1, 75: 1, 78: 1, 85: 1 }),
107
+ depth: Object.freeze({ 0: 3854, 1: 1478, 2: 482, 3: 140, 4: 47, 5: 27, 6: 5, 7: 1 }),
108
+ heavyShare: Object.freeze([0.421, 0.3361, 0.4049, 0, 0.2979, 0.3647, 0.4107, 0.6167, 0.2635, 0.8434, 0, 0, 0.193, 0.6399, 0.4063, 0.4769, 0.4322, 0.5337, 0.3628, 0.6626, 0.0554, 0.7775, 0.3548, 0.3285, 0, 0.6801, 0.2222, 0.3084, 0.3705, 0.6468, 0.3928, 0.6786, 0.3247, 0.5225, 0, 0.1462, 0, 0.5817, 0.5891, 0.3835, 0.2491, 0.5627, 0.2439, 0.3643, 0.6719, 0.6525, 0.4385, 0.565, 0.5672]),
104
109
  }),
105
110
  })
106
111
 
@@ -123,16 +128,42 @@ export function rankOf(entry) {
123
128
  }
124
129
 
125
130
  /**
126
- * The size score of a tree: 10 minus the mean rank of its functions among
127
- * the 6040 listed ones, on 0 to 10 with two decimals. A tree of median
128
- * functions scores 5.00; every function made shorter, flatter or less
129
- * branched raises it. It says where the tree sits among listed plugins,
130
- * never whether it is good, and a tree with no function has no score.
131
+ * The share of a tree's function lines that sit in functions over any of
132
+ * the M12 thresholds: lines inside `overSize` functions over lines inside
133
+ * every function, 0 with no function. Line-weighted on purpose: splitting
134
+ * one long function into short ones moves its lines out of the heavy set,
135
+ * and padding a tree with small functions barely moves the ratio.
136
+ */
137
+ export function heavyShare(functions) {
138
+ const total = functions.reduce((sum, entry) => sum + entry.lines, 0)
139
+ if (!total) return 0
140
+ const heavy = overSize(functions).reduce((sum, entry) => sum + entry.lines, 0)
141
+ return heavy / total
142
+ }
143
+
144
+ /**
145
+ * The share of listed trees whose heavyShare is strictly smaller than this
146
+ * one, as a percentage over the listed trees that have a share: 0 for a
147
+ * tree with no function over the thresholds, 100 for one heavier than
148
+ * every listed tree.
149
+ */
150
+ export function treeRank(share) {
151
+ const shares = SIZE.distribution.heavyShare
152
+ const below = shares.filter((listed) => listed < share).length
153
+ return Math.round((below / shares.length) * 1000) / 10
154
+ }
155
+
156
+ /**
157
+ * The size score of a tree: 10 minus its rank among the listed trees
158
+ * divided by 10, on 0 to 10 with two decimals. A tree with no function
159
+ * over the thresholds scores 10.00, a tree heavier than every listed tree
160
+ * 0.00. It says where the tree sits among listed plugins by how much of
161
+ * its function text is in long functions, never whether it is good, and a
162
+ * tree with no function has no score.
131
163
  */
132
164
  export function sizeScore(functions) {
133
165
  if (!functions.length) return null
134
- const mean = functions.reduce((sum, entry) => sum + rankOf(entry), 0) / functions.length
135
- return Math.round((10 - mean / 10) * 100) / 100
166
+ return Math.round((10 - treeRank(heavyShare(functions)) / 10) * 100) / 100
136
167
  }
137
168
 
138
169
  /** The functions over any of the thresholds, longest first, then most branched. */
@@ -27,6 +27,34 @@ const NOTHING = "observed nothing of this kind"
27
27
  const plural = (count, word, words = `${word}s`) => `${count} ${count === 1 ? word : words}`
28
28
  const site = (row) => `${row.file}:${row.line}`
29
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
+ /**
39
+ * The score sentence after the number: the share, then the position among
40
+ * the listed trees the document itself carries. The rank counts the listed
41
+ * trees with a strictly smaller share, so the tree is "no heavier than"
42
+ * the rest: a tie is level, not lighter.
43
+ */
44
+ function scoreText(size) {
45
+ const shares = size.sample.heavyShares
46
+ if (!shares.length) return `${percent(size.heavyShare)} of its function lines sit in functions over the measured size; no listed tree to place it among (${size.measurement})`
47
+ const lighter = shares.filter((share) => share < size.heavyShare).length
48
+ return `${percent(size.heavyShare)} of its function lines sit in functions over the measured size, no heavier than ${shares.length - lighter} of ${shares.length} listed trees (${size.measurement})`
49
+ }
50
+
51
+ /** Under --allow-dirty: what the checkout holds that the tree at the commit does not. */
52
+ function uncommittedLine(document, c) {
53
+ const count = document.subject.uncommittedFiles
54
+ if (!count) return []
55
+ 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)
56
+ }
57
+
30
58
  /** A row: a mark, the site in bold, then the text, wrapped under the gutter. */
31
59
  function row(state, head, text, c, extra = []) {
32
60
  const lines = wrap(text || "(nothing)", { indent: GUTTER, first: GUTTER + head.length + 2 }, c)
@@ -233,11 +261,12 @@ export function renderInspect(document, { colour = colourEnabled(), full = false
233
261
  const out = []
234
262
  const counts = document.counts
235
263
  out.push(...field("subject", `${withHomeAbbreviated(document.subject.dir)} at ${document.subject.commit ? document.subject.commit.slice(0, 8) : "no commit"}`, c))
264
+ out.push(...uncommittedLine(document, c))
236
265
  out.push(...field("baseline", baselineText(document.marketplaceBaseline), c))
237
266
  const score = document.size.score
238
267
  out.push(...field("size score", score === null
239
268
  ? `none: no function to rank`
240
- : `${score.toFixed(2)} of 10; 10 minus the mean rank of its ${plural(counts.functions, "function")} among ${SIZE.functions} in ${document.size.sample.trees} listed trees (${SIZE.measurement}), so a tree of median functions scores 5.00`, c))
269
+ : `${score.toFixed(2)} of 10; ${scoreText(document.size)}`, c))
241
270
  out.push("")
242
271
 
243
272
  const ranked = [...document.patterns].sort((a, b) => b.share - a.share)
@@ -248,7 +277,12 @@ export function renderInspect(document, { colour = colourEnabled(), full = false
248
277
  if (!ranked.length && !over.length) {
249
278
  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))
250
279
  } else {
251
- 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))
280
+ const classes = shown.length
281
+ ? `${plural(shown.length, "class", "classes")} reviewers raise, biggest first by share of review findings (${PATTERNS[0].measurement}); up to ${SHOWN_SITES} sites each`
282
+ : below.length
283
+ ? `only classes under ${Math.round(MIN_SHARE * 100)}% of review findings show (${PATTERNS[0].measurement}), counted below`
284
+ : `none of the classes reviewers raise shows in this tree (${PATTERNS[0].measurement})`
285
+ out.push(...field("attention", `${over.length ? `long functions first, by length${shown.length ? ", then " : "; "}` : ""}${classes}`, c))
252
286
  }
253
287
  // Long functions first: what the person asked about, so the order is a
254
288
  // preference and the heading says whose thresholds it uses.
@@ -300,6 +334,7 @@ function renderFull(document, { colour }) {
300
334
  const kinds = Object.entries(read).filter(([, count]) => count > 0).map(([kind, count]) => `${count} ${kind}`)
301
335
  const total = Object.values(read).reduce((sum, count) => sum + count, 0)
302
336
  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))
337
+ out.push(...uncommittedLine(document, c))
303
338
  out.push(...field("method", document.method, c))
304
339
  out.push("")
305
340
 
@@ -320,7 +355,7 @@ function renderFull(document, { colour }) {
320
355
  ? `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`
321
356
  : NOTHING, c))
322
357
  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))
323
- if (functions.length) out.push(...wrap(`size score ${document.size.score.toFixed(2)} of 10: 10 minus the mean rank of the ${functions.length} among ${SIZE.functions} listed functions (${SIZE.measurement})`, { indent: GUTTER }, c))
358
+ if (functions.length) out.push(...wrap(`size score ${document.size.score.toFixed(2)} of 10: ${scoreText(document.size)}`, { indent: GUTTER }, c))
324
359
  out.push("")
325
360
  out.push(...baselineLines(document.marketplaceBaseline, c))
326
361
  out.push("")
@@ -68,7 +68,8 @@ the tree, no verdict:
68
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
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
70
  | `inspect/timers.mjs` | `Timer {` blocks: interval, repeat, running, triggeredOnStart, and the handler outside the block that starts it. |
71
- | `inspect/functions.mjs` | Function sites: `function name(` and multi-line handlers in QML and JavaScript, shell functions, Python defs, each with its length in lines, deepest nesting and branch count. |
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`. |
72
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. |
73
74
  | `inspect/contract.mjs` | The JSON contract of docs/INSPECT.md as a validator, run by the unit tests over every fixture document. |
74
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`. |
@@ -58,7 +58,7 @@ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..")
58
58
  const REMEDY = Object.freeze({
59
59
  "usage": "omakit help",
60
60
  "marketplace-unavailable": "omakit pin",
61
- "dirty-worktree": "Commit the changes, or pass --allow-dirty to check the tree as it is.",
61
+ "dirty-worktree": "Commit the changes, or pass --allow-dirty to read HEAD as committed; uncommitted edits are not read.",
62
62
  "subject-not-found": "Pass a local Git repository path, or <https url>@<40-char sha>.",
63
63
  "not-a-git-repository": "Pass a local Git repository path, or <https url>@<40-char sha>.",
64
64
  "commit-not-found": "Commit first; the checks read the tree at an exact commit, never the working copy.",
@@ -105,8 +105,9 @@ export const COMMANDS = Object.freeze([
105
105
  "each with its measured share, only where the tree shows the class.",
106
106
  "Regular expressions over QML and shell, labelled observed; runs nothing",
107
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, 10 minus",
109
- "the mean rank of the tree's functions among those in listed trees,",
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;",
110
111
  "then what needs attention: functions over the measured size, longest",
111
112
  "first, then the review classes by measured share, five sites each;",
112
113
  "--full is every site with every qualifier; --json prints the document.",
@@ -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
- const clean = git(top, ["status", "--porcelain"]).trim().length === 0
77
- if (!clean && !options.allowDirty) throw new SubjectError("dirty-worktree", `${top} has uncommitted changes; commit them or pass --allow-dirty`)
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
  }