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 +1 -1
- 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 +55 -13
- package/tools/inspect/inspect.mjs +17 -5
- package/tools/inspect/measure-functions.mjs +150 -0
- package/tools/inspect/patterns.mjs +44 -15
- package/tools/inspect/report.mjs +26 -2
- 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
|
@@ -39,7 +39,7 @@ Compares your installed plugin commits with the marketplace's validated commits.
|
|
|
39
39
|
|
|
40
40
|

|
|
41
41
|
|
|
42
|
-
Reads a plugin's tree and prints what needs attention, biggest first: a size score (
|
|
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.
|
|
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.
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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
|
|
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,7 +1,8 @@
|
|
|
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
|
|
|
@@ -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
|
-
/**
|
|
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 === "}")
|
|
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
|
-
//
|
|
41
|
-
const
|
|
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
|
-
|
|
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[
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
141
|
-
//
|
|
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,
|
|
92
|
+
sample: "50 listed trees, 6041 functions",
|
|
93
93
|
trees: 50,
|
|
94
|
-
functions:
|
|
94
|
+
functions: 6041,
|
|
95
95
|
lines: 22,
|
|
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 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:
|
|
102
|
-
branches: Object.freeze({ 0: 2336, 1: 1096, 2: 807, 3:
|
|
103
|
-
depth: Object.freeze({ 0:
|
|
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
|
|
127
|
-
* the
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
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
|
-
|
|
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. */
|
package/tools/inspect/report.mjs
CHANGED
|
@@ -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;
|
|
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:
|
|
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(
|
|
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
|
}
|