omakit 0.4.3 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -39,7 +39,7 @@ Compares your installed plugin commits with the marketplace's validated commits.
39
39
 
40
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)
41
41
 
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)).
42
+ 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 50 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
43
 
44
44
  ## `omakit weigh <plugin>`
45
45
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omakit",
3
- "version": "0.4.3",
3
+ "version": "0.5.0",
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 listed trees' shares). 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 !== 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")
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,7 +1,8 @@
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
 
@@ -13,11 +14,22 @@ const SHELL_CLOSE = /^\s*(?:fi|done|esac)\b/
13
14
  const SHELL_BRANCH = /\b(?:if|elif|for|while|until|case)\b|\|\||&&|^\s*[^)]*\)\s*(?!\s*$)/
14
15
  const PY_BRANCH = /^\s*(?:if|elif|for|while|except|with)\b|\band\b|\bor\b/
15
16
 
16
- /** Deepest brace nesting inside a body, relative to the body itself. */
17
+ /**
18
+ * Deepest brace nesting inside a body, relative to the body itself. A `{`
19
+ * that opens a literal is not a level: an object or array literal spread
20
+ * over lines (`return {`, `foo({`, `x = {`, `[{`, `? {`, `a || {`) and an
21
+ * inline arrow body (`=> {`) are values, not control flow. The test is the
22
+ * last non-space text before the brace, so this is a regular-expression
23
+ * heuristic, not a parser: it aims at an object literal not counting as
24
+ * nesting, and a `{` after `)` or `else` or on a line of its own counts.
25
+ */
26
+ const LITERAL_BEFORE = /(?:[(,:=[?]|\breturn|=>|\|\||&&)\s*$/
27
+
17
28
  function braceDepth(body) {
18
29
  let depth = 0
19
30
  let deepest = 0
20
31
  let quote = null
32
+ const literal = []
21
33
  for (let i = 0; i < body.length; i += 1) {
22
34
  const ch = body[i]
23
35
  if (quote) {
@@ -27,30 +39,50 @@ function braceDepth(body) {
27
39
  }
28
40
  if (ch === '"' || ch === "'" || ch === "`") quote = ch
29
41
  else if (ch === "{") {
42
+ const isLiteral = LITERAL_BEFORE.test(body.slice(Math.max(0, i - 12), i))
43
+ literal.push(isLiteral)
44
+ if (isLiteral) continue
30
45
  depth += 1
31
46
  if (depth > deepest) deepest = depth
32
- } else if (ch === "}") depth -= 1
47
+ } else if (ch === "}") {
48
+ if (!literal.pop()) depth -= 1
49
+ }
33
50
  }
34
51
  return deepest
35
52
  }
36
53
 
54
+ /** Words that stand before `(...) {` without naming a method. */
55
+ const NOT_A_METHOD = new Set(["if", "for", "while", "switch", "catch", "with", "function", "return", "else", "do", "try"])
56
+
37
57
  function jsFunctions(file) {
38
58
  const text = blankComments(file.text)
39
59
  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
60
+ // Four shapes, each followed by a body block: `function name(`; in QML,
61
+ // `onSomething: {` handlers; a named arrow function, `const load = (rows)
62
+ // => {` or `this.load = rows => {` on a property; and method shorthand,
63
+ // `load(rows) {` at the start of a line inside an object literal or a
64
+ // class. An anonymous callback (`(x) => {`, `function (x) {`) has no name
65
+ // and is not counted; the method shape excludes the keywords that stand
66
+ // before `(...) {` (`if`, `for`, `while`, `switch`, `catch`).
67
+ const pattern = new RegExp([
68
+ /(?<![\w.])function\s+([A-Za-z_$][\w$]*)\s*\(/.source,
69
+ /(?<![\w.])(on[A-Z]\w*)\s*:\s*(?=\{)/.source,
70
+ /(?<![\w$])([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:\([^()\n]*\)|[A-Za-z_$][\w$]*)\s*=>[ \t]*(?=\{)/.source,
71
+ /^[ \t]*(?:(?:async|static)\s+)*([A-Za-z_$][\w$]*)[ \t]*\([^()\n]*\)[ \t]*(?=\{)/.source,
72
+ ].join("|"), "gm")
42
73
  for (const match of text.matchAll(pattern)) {
43
- const open = text.indexOf("{", match.index + match[0].length - (match[2] ? 0 : 0))
74
+ if (match[4] && NOT_A_METHOD.has(match[4])) continue
75
+ const open = text.indexOf("{", match.index + match[0].length)
44
76
  if (open < 0) continue
45
77
  const end = closingBracket(text, open)
46
78
  if (end < 0) continue
47
79
  const body = text.slice(open + 1, end)
48
- const start = lineOf(text, match.index)
80
+ const start = lineOf(text, match.index + match[0].length - match[0].trimStart().length)
49
81
  rows.push({
50
82
  file: file.path,
51
83
  line: start,
52
- name: match[1] || match[2],
53
- kind: match[1] ? "function" : "handler",
84
+ name: match[1] || match[2] || match[3] || match[4],
85
+ kind: match[2] ? "handler" : "function",
54
86
  lines: lineOf(text, end) - start + 1,
55
87
  depth: braceDepth(body),
56
88
  branches: (body.match(JS_BRANCH) || []).length,
@@ -102,12 +134,22 @@ function pythonFunctions(file) {
102
134
  let end = index
103
135
  let deepest = 0
104
136
  let branches = 0
137
+ // Depth is relative to the body, the way it is for a brace block: the
138
+ // body's own indent is depth 0 and one `if` is depth 1. The unit is
139
+ // one indentation step, read from the first body line (4 spaces by
140
+ // PEP 8, 2 in some trees); 4 is assumed when there is no body line.
141
+ let unit = 4
142
+ let first = true
105
143
  for (let at = index + 1; at < lines.length; at += 1) {
106
144
  const line = lines[at]
107
145
  if (!line.trim()) continue
108
146
  const indent = line.match(/^\s*/)[0].length
109
147
  if (indent <= base) break
110
- const level = Math.floor((indent - base) / 4)
148
+ if (first) {
149
+ unit = indent - base
150
+ first = false
151
+ }
152
+ const level = Math.max(0, Math.floor((indent - base) / unit) - 1)
111
153
  if (level > deepest) deepest = level
112
154
  if (PY_BRANCH.test(line)) branches += 1
113
155
  end = at
@@ -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,15 @@ 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, 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] },
139
149
  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
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
142
153
  // function to rank.
154
+ heavyShare: Math.round(heavyShare(functions) * 10000) / 10000,
143
155
  score: sizeScore(functions),
144
156
  over: overSize(functions),
145
157
  },
@@ -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
+ }
@@ -89,18 +89,22 @@ 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, 6041 functions",
93
93
  trees: 50,
94
- functions: 6040,
94
+ functions: 6041,
95
95
  lines: 22,
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 6041 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, from which
102
+ // a tree's position among listed trees is read.
100
103
  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 }),
104
+ lines: Object.freeze({ 1: 330, 2: 185, 3: 682, 4: 659, 5: 629, 6: 515, 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 }),
105
+ branches: Object.freeze({ 0: 2336, 1: 1096, 2: 807, 3: 455, 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 }),
106
+ depth: Object.freeze({ 0: 3794, 1: 1421, 2: 481, 3: 155, 4: 80, 5: 62, 6: 27, 7: 10, 8: 5, 9: 2, 12: 1, 13: 1, 15: 1, 18: 1 }),
107
+ heavyShare: Object.freeze([0.421, 0.3361, 0.4049, 0, 0.3687, 0.4469, 0.4107, 0.6167, 0.2635, 0.8434, 0, 0, 0.193, 0.7036, 0.4506, 0.4846, 0.5047, 0.5337, 0.3628, 0.682, 0.0554, 0.7775, 0.3548, 0.3552, 0, 0.7172, 0.2542, 0.3747, 0.4275, 0.6607, 0.4322, 0.6941, 0.3247, 0.5225, 0.2545, 0.1462, 0, 0.5817, 0.5891, 0.4353, 0, 0.3479, 0.6109, 0.296, 0.3882, 0.5624, 0.6783, 0.4385, 0.6078, 0.5826]),
104
108
  }),
105
109
  })
106
110
 
@@ -123,16 +127,41 @@ export function rankOf(entry) {
123
127
  }
124
128
 
125
129
  /**
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.
130
+ * The share of a tree's function lines that sit in functions over any of
131
+ * the M12 thresholds: lines inside `overSize` functions over lines inside
132
+ * every function, 0 with no function. Line-weighted on purpose: splitting
133
+ * one long function into short ones moves its lines out of the heavy set,
134
+ * and padding a tree with small functions barely moves the ratio.
135
+ */
136
+ export function heavyShare(functions) {
137
+ const total = functions.reduce((sum, entry) => sum + entry.lines, 0)
138
+ if (!total) return 0
139
+ const heavy = overSize(functions).reduce((sum, entry) => sum + entry.lines, 0)
140
+ return heavy / total
141
+ }
142
+
143
+ /**
144
+ * The share of listed trees whose heavyShare is strictly smaller than this
145
+ * one, as a percentage over the trees of the M12 record: 0 for a tree with
146
+ * no function over the thresholds, 100 for one heavier than every listed
147
+ * tree.
148
+ */
149
+ export function treeRank(share) {
150
+ const below = SIZE.distribution.heavyShare.filter((listed) => listed < share).length
151
+ return Math.round((below / SIZE.trees) * 1000) / 10
152
+ }
153
+
154
+ /**
155
+ * The size score of a tree: 10 minus its rank among the listed trees
156
+ * divided by 10, on 0 to 10 with two decimals. A tree with no function
157
+ * over the thresholds scores 10.00, a tree heavier than every listed tree
158
+ * 0.00. It says where the tree sits among listed plugins by how much of
159
+ * its function text is in long functions, never whether it is good, and a
160
+ * tree with no function has no score.
131
161
  */
132
162
  export function sizeScore(functions) {
133
163
  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
164
+ return Math.round((10 - treeRank(heavyShare(functions)) / 10) * 100) / 100
136
165
  }
137
166
 
138
167
  /** The functions over any of the thresholds, longest first, then most branched. */
@@ -27,6 +27,28 @@ 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
+ /** The score sentence after the number: the share, then the position among the listed trees the document itself carries. */
39
+ function scoreText(size) {
40
+ const shares = size.sample.heavyShares
41
+ const rank = (shares.filter((share) => share < size.heavyShare).length / shares.length) * 100
42
+ return `${percent(size.heavyShare)} of its function lines sit in functions over the measured size, less than ${100 - Math.round(rank)} of 100 listed trees (${size.measurement})`
43
+ }
44
+
45
+ /** Under --allow-dirty: what the checkout holds that the tree at the commit does not. */
46
+ function uncommittedLine(document, c) {
47
+ const count = document.subject.uncommittedFiles
48
+ if (!count) return []
49
+ return field("uncommitted", `${plural(count, "file")} differ${count === 1 ? "s" : ""} from HEAD and ${count === 1 ? "was" : "were"} not inspected; the tree at ${document.subject.commit ? document.subject.commit.slice(0, 8) : "the commit"} is what was read`, c)
50
+ }
51
+
30
52
  /** A row: a mark, the site in bold, then the text, wrapped under the gutter. */
31
53
  function row(state, head, text, c, extra = []) {
32
54
  const lines = wrap(text || "(nothing)", { indent: GUTTER, first: GUTTER + head.length + 2 }, c)
@@ -233,11 +255,12 @@ export function renderInspect(document, { colour = colourEnabled(), full = false
233
255
  const out = []
234
256
  const counts = document.counts
235
257
  out.push(...field("subject", `${withHomeAbbreviated(document.subject.dir)} at ${document.subject.commit ? document.subject.commit.slice(0, 8) : "no commit"}`, c))
258
+ out.push(...uncommittedLine(document, c))
236
259
  out.push(...field("baseline", baselineText(document.marketplaceBaseline), c))
237
260
  const score = document.size.score
238
261
  out.push(...field("size score", score === null
239
262
  ? `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))
263
+ : `${score.toFixed(2)} of 10; ${scoreText(document.size)}`, c))
241
264
  out.push("")
242
265
 
243
266
  const ranked = [...document.patterns].sort((a, b) => b.share - a.share)
@@ -300,6 +323,7 @@ function renderFull(document, { colour }) {
300
323
  const kinds = Object.entries(read).filter(([, count]) => count > 0).map(([kind, count]) => `${count} ${kind}`)
301
324
  const total = Object.values(read).reduce((sum, count) => sum + count, 0)
302
325
  out.push(...field("subject", `${withHomeAbbreviated(document.subject.dir)} at ${document.subject.commit ? document.subject.commit.slice(0, 8) : "no commit"}, ${plural(total, "file")} read${kinds.length ? ` (${kinds.join(", ")})` : ""}`, c))
326
+ out.push(...uncommittedLine(document, c))
303
327
  out.push(...field("method", document.method, c))
304
328
  out.push("")
305
329
 
@@ -320,7 +344,7 @@ function renderFull(document, { colour }) {
320
344
  ? `observed ${functions.length}, ${over.length} over what 90 of 100 functions in ${SIZE.sample} stay under (${SIZE.lines} lines, ${SIZE.branches} branches or nesting ${SIZE.depth}; ${SIZE.measurement}), longest first`
321
345
  : NOTHING, c))
322
346
  for (const entry of over) out.push(...row("info", `${entry.file}:${entry.line}`, `${entry.name}, ${plural(entry.lines, "line")}, ${plural(entry.branches, "branch", "branches")}, nesting ${entry.depth}, over ${entry.percentile} of 100 listed`, c))
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))
347
+ if (functions.length) out.push(...wrap(`size score ${document.size.score.toFixed(2)} of 10: ${scoreText(document.size)}`, { indent: GUTTER }, c))
324
348
  out.push("")
325
349
  out.push(...baselineLines(document.marketplaceBaseline, c))
326
350
  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
  }