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 +16 -2
- package/package.json +1 -1
- package/skills/omarchy-plugin-check/SKILL.md +11 -5
- package/tools/inspect/contract.mjs +15 -4
- package/tools/inspect/functions.mjs +291 -25
- package/tools/inspect/inspect.mjs +18 -5
- package/tools/inspect/measure-functions.mjs +159 -0
- package/tools/inspect/patterns.mjs +47 -16
- package/tools/inspect/report.mjs +38 -3
- package/tools/marketplace/README.md +2 -1
- package/tools/marketplace/cli.mjs +1 -1
- package/tools/marketplace/usage.mjs +3 -2
- package/tools/subject/resolve.mjs +10 -3
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
|
[](https://github.com/tcballard/omarchy-badges) [](https://www.npmjs.com/package/omakit) [](https://github.com/mtolhuys/omakit/actions/workflows/ci.yml) [](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
|
-

|
|
41
55
|
|
|
42
|
-
Reads a plugin's tree and prints what needs attention, biggest first: a size score (
|
|
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.
|
|
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.
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
the
|
|
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
|
-
|
|
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
|
|
177
|
-
|
|
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,
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
/**
|
|
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 === "}")
|
|
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
|
-
//
|
|
41
|
-
const
|
|
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
|
-
|
|
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[
|
|
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 (
|
|
240
|
+
if (heredoc) {
|
|
241
|
+
if ((heredoc.strip ? line.replace(/^\t+/, "") : line) === heredoc.word) heredoc = null
|
|
78
242
|
end = at
|
|
79
|
-
|
|
243
|
+
continue
|
|
80
244
|
}
|
|
81
|
-
if (
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
141
|
-
//
|
|
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,
|
|
92
|
+
sample: "50 listed trees, 6034 functions",
|
|
93
93
|
trees: 50,
|
|
94
|
-
functions:
|
|
95
|
-
lines:
|
|
94
|
+
functions: 6034,
|
|
95
|
+
lines: 25,
|
|
96
96
|
branches: 6,
|
|
97
|
-
depth:
|
|
98
|
-
// The histogram of each measure over the
|
|
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:
|
|
102
|
-
branches: Object.freeze({ 0:
|
|
103
|
-
depth: Object.freeze({ 0:
|
|
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
|
|
127
|
-
* the
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
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
|
-
|
|
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. */
|
package/tools/inspect/report.mjs
CHANGED
|
@@ -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;
|
|
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
|
-
|
|
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:
|
|
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(
|
|
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
|
|
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,
|
|
109
|
-
"
|
|
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
|
-
|
|
77
|
-
|
|
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
|
}
|