omakit 0.5.0 → 0.6.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 +39 -32
- package/blocks/history.json +68 -0
- package/blocks/run/NOTICE +12 -0
- package/blocks/run/Run.qml +242 -0
- package/blocks/run/run-supervisor.py +522 -0
- package/blocks/store/NOTICE +12 -0
- package/blocks/store/Store.qml +157 -0
- package/blocks/store/store-helper.py +431 -0
- package/package.json +12 -5
- package/skills/omarchy-plugin-audit/SKILL.md +11 -5
- package/skills/omarchy-plugin-build/SKILL.md +164 -0
- package/skills/omarchy-plugin-check/SKILL.md +7 -4
- package/skills/omarchy-plugin-submit/SKILL.md +4 -1
- package/skills/omarchy-plugin-validation-watch/SKILL.md +5 -2
- package/skills/omarchy-plugin-weigh/SKILL.md +13 -2
- package/tests/fixtures/weigh/clean/Widget.qml +19 -0
- package/tests/fixtures/weigh/clean/manifest.json +9 -0
- package/tests/fixtures/weigh/clean/tests/harness.qml +7 -0
- package/tests/fixtures/weigh/idle-panel/Panel.qml +65 -0
- package/tests/fixtures/weigh/idle-panel/manifest.json +9 -0
- package/tests/fixtures/weigh/poller/Service.qml +50 -0
- package/tests/fixtures/weigh/poller/manifest.json +9 -0
- package/tests/fixtures/weigh/timer-180ms/Widget.qml +25 -0
- package/tests/fixtures/weigh/timer-180ms/manifest.json +9 -0
- package/tests/lab/run/harness/scenarios/controls.sh +6 -0
- package/tests/lab/run/harness/scenarios/envprobe.sh +10 -0
- package/tests/lab/run/harness/scenarios/forge.sh +11 -0
- package/tests/lab/run/harness/scenarios/holder.sh +5 -0
- package/tests/lab/run/harness/scenarios/orphan.sh +6 -0
- package/tests/lab/run/harness/scenarios/stall.sh +5 -0
- package/tests/lab/run/harness/scenarios/stubborn.sh +5 -0
- package/tests/lab/run/harness/scenarios/tree.sh +7 -0
- package/tests/lab/run/harness/shell.qml +84 -0
- package/tests/lab/run/report.py +217 -0
- package/tests/lab/run/suite.sh +106 -0
- package/tests/lab/store/harness/shell.qml +73 -0
- package/tests/lab/store/report.py +133 -0
- package/tests/lab/store/suite.sh +109 -0
- package/tests/parity/corpus.mjs +8 -3
- package/tests/parity/run.mjs +4 -4
- package/tools/audit/audit.mjs +17 -6
- package/tools/audit/git.mjs +3 -3
- package/tools/audit/report.mjs +31 -5
- package/tools/blocks/add.mjs +138 -0
- package/tools/blocks/commit.json +5 -0
- package/tools/blocks/record-commit.mjs +77 -0
- package/tools/blocks/registry.mjs +191 -0
- package/tools/blocks/stamp.mjs +61 -0
- package/tools/inspect/contract.mjs +37 -6
- package/tools/inspect/functions.mjs +236 -12
- package/tools/inspect/helpers.mjs +217 -0
- package/tools/inspect/inspect.mjs +71 -5
- package/tools/inspect/measure-functions.mjs +12 -3
- package/tools/inspect/patterns.mjs +35 -18
- package/tools/inspect/processes.mjs +38 -5
- package/tools/inspect/report.mjs +33 -6
- package/tools/inspect/writes.mjs +22 -4
- package/tools/lab/guest.mjs +155 -0
- package/tools/lab/harness.sh +119 -0
- package/tools/lab/host.mjs +177 -0
- package/tools/lab/inspect.mjs +240 -0
- package/tools/lab/omarchy.gpg +13 -0
- package/tools/lab/patches/omarchy-iso-test.patch +351 -0
- package/tools/lab/paths.mjs +173 -0
- package/tools/lab/pin.json +42 -0
- package/tools/lab/pin.mjs +64 -0
- package/tools/lab/prune.mjs +68 -0
- package/tools/lab/qemu.mjs +153 -0
- package/tools/lab/qmp-cli.mjs +21 -0
- package/tools/lab/report.mjs +183 -0
- package/tools/lab/run.mjs +344 -0
- package/tools/lab/setup.mjs +430 -0
- package/tools/lab/suites/run.sh +35 -0
- package/tools/lab/suites/store.sh +41 -0
- package/tools/lab/suites/weigh.sh +196 -0
- package/tools/lab/suites.mjs +142 -0
- package/tools/lab/verify.mjs +134 -0
- package/tools/marketplace/README.md +38 -1
- package/tools/marketplace/banner.mjs +23 -2
- package/tools/marketplace/cli.mjs +449 -146
- package/tools/marketplace/completion-check.mjs +27 -1
- package/tools/marketplace/completion.mjs +32 -4
- package/tools/marketplace/doctor.mjs +47 -9
- package/tools/marketplace/github.mjs +52 -6
- package/tools/marketplace/local-transport.mjs +1 -1
- package/tools/marketplace/options.mjs +16 -5
- package/tools/marketplace/outcome.mjs +244 -0
- package/tools/marketplace/pin.mjs +178 -33
- package/tools/marketplace/setup.mjs +16 -15
- package/tools/marketplace/tree.mjs +1 -1
- package/tools/marketplace/upgrade.mjs +5 -5
- package/tools/marketplace/usage.mjs +116 -72
- package/tools/subject/resolve.mjs +19 -6
- package/tools/weigh/audit.mjs +47 -16
- package/tools/weigh/config.mjs +105 -24
- package/tools/weigh/list.mjs +10 -1
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// The blocks omakit ships, read from blocks/<name>/ in this checkout or
|
|
2
|
+
// package: which files each has, their headers, and the sha256 of every
|
|
3
|
+
// file's body, which is what `omakit add` writes, what it refuses to
|
|
4
|
+
// overwrite when it differs, and what `omakit inspect` recognises an
|
|
5
|
+
// unmodified copy by. Nothing here is a marketplace rule (AGENTS.md, rule
|
|
6
|
+
// 2): a block is behaviour measured from public review comments (M13,
|
|
7
|
+
// docs/BLOCKS.md), and this module only knows the block's own files.
|
|
8
|
+
//
|
|
9
|
+
// A file's header is its leading comment lines up to and including the
|
|
10
|
+
// line `end of omakit block header`; the body is everything after that
|
|
11
|
+
// line. The header names the block and its version, the licence, the
|
|
12
|
+
// copyright, where the file came from (the commit is stamped by `add`,
|
|
13
|
+
// so it is the one line a copy may differ from the shipped file in) and
|
|
14
|
+
// the body's sha256, so a person can check a copy with sha256sum alone.
|
|
15
|
+
|
|
16
|
+
import { createHash } from "node:crypto"
|
|
17
|
+
import { readdirSync, readFileSync } from "node:fs"
|
|
18
|
+
import { dirname, join, resolve } from "node:path"
|
|
19
|
+
import { fileURLToPath } from "node:url"
|
|
20
|
+
|
|
21
|
+
export const BLOCKS_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "../../blocks")
|
|
22
|
+
|
|
23
|
+
export const HEADER_END = "end of omakit block header"
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A block that uses another block's files from the same omakit/ directory:
|
|
27
|
+
* `omakit add` writes the required block too, and inspect reads the
|
|
28
|
+
* requirement when it says whether a block is complete.
|
|
29
|
+
*/
|
|
30
|
+
export const REQUIRES = Object.freeze({ store: Object.freeze(["run"]) })
|
|
31
|
+
|
|
32
|
+
/** The blocks to add for one name: its requirements first, then itself, each once. */
|
|
33
|
+
export function blockClosure(name) {
|
|
34
|
+
const names = []
|
|
35
|
+
for (const required of REQUIRES[name] || []) for (const entry of blockClosure(required)) if (!names.includes(entry)) names.push(entry)
|
|
36
|
+
if (!names.includes(name)) names.push(name)
|
|
37
|
+
return names
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Split a block file's text into its header lines and its body. Null when
|
|
42
|
+
* the text has no block header, which is how a plugin's own file reads.
|
|
43
|
+
*
|
|
44
|
+
* @param {string} text
|
|
45
|
+
* @returns {{ header: string[], body: string, name: string, version: string, sha256: string|null } | null}
|
|
46
|
+
*/
|
|
47
|
+
export function parseHeader(text) {
|
|
48
|
+
const lines = String(text).split("\n")
|
|
49
|
+
const end = lines.findIndex((line) => line.replace(/^(?:\/\/|#)\s*/, "").trim() === HEADER_END)
|
|
50
|
+
if (end < 0 || end > 12) return null
|
|
51
|
+
const header = lines.slice(0, end + 1)
|
|
52
|
+
const strip = (line) => line.replace(/^(?:\/\/|#)\s?/, "")
|
|
53
|
+
const named = header.map(strip).find((line) => /^omakit block: /.test(line))
|
|
54
|
+
if (!named) return null
|
|
55
|
+
const match = named.match(/^omakit block: ([a-z][a-z0-9-]*) (\d+\.\d+\.\d+)$/)
|
|
56
|
+
if (!match) return null
|
|
57
|
+
const sha = header.map(strip).find((line) => /^Body sha256: /.test(line))?.slice("Body sha256: ".length).trim() || null
|
|
58
|
+
return { header, body: lines.slice(end + 1).join("\n"), name: match[1], version: match[2], sha256: sha && /^[0-9a-f]{64}$/.test(sha) ? sha : null }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function bodySha256(body) {
|
|
62
|
+
return createHash("sha256").update(body, "utf8").digest("hex")
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Every shipped block: its name, version, and files with their parsed
|
|
67
|
+
* headers and body hashes, from blocks/<name>/ (NOTICE is the listing, not
|
|
68
|
+
* a block file, and has no header).
|
|
69
|
+
*
|
|
70
|
+
* @param {string} [dir]
|
|
71
|
+
* @returns {Array<{ name: string, version: string, dir: string, files: Array<{ file: string, text: string, header: string[], body: string, sha256: string, headerSha256: string|null }>, notice: string }>}
|
|
72
|
+
*/
|
|
73
|
+
export function shippedBlocks(dir = BLOCKS_DIR) {
|
|
74
|
+
const blocks = []
|
|
75
|
+
for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : 1))) {
|
|
76
|
+
if (!entry.isDirectory()) continue
|
|
77
|
+
const blockDir = join(dir, entry.name)
|
|
78
|
+
const files = []
|
|
79
|
+
let notice = ""
|
|
80
|
+
for (const file of readdirSync(blockDir).sort()) {
|
|
81
|
+
const text = readFileSync(join(blockDir, file), "utf8")
|
|
82
|
+
if (file === "NOTICE") {
|
|
83
|
+
notice = text
|
|
84
|
+
continue
|
|
85
|
+
}
|
|
86
|
+
const parsed = parseHeader(text)
|
|
87
|
+
if (!parsed) throw new Error(`blocks/${entry.name}/${file} has no block header`)
|
|
88
|
+
if (parsed.name !== entry.name) throw new Error(`blocks/${entry.name}/${file} names block ${parsed.name}`)
|
|
89
|
+
files.push({ file, text, header: parsed.header, body: parsed.body, sha256: bodySha256(parsed.body), headerSha256: parsed.sha256, version: parsed.version })
|
|
90
|
+
}
|
|
91
|
+
if (!files.length) continue
|
|
92
|
+
const versions = new Set(files.map((file) => file.version))
|
|
93
|
+
if (versions.size !== 1) throw new Error(`blocks/${entry.name} carries ${versions.size} versions`)
|
|
94
|
+
blocks.push({ name: entry.name, version: files[0].version, dir: blockDir, files, notice })
|
|
95
|
+
}
|
|
96
|
+
return blocks
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** One shipped block by name, or null. */
|
|
100
|
+
export function shippedBlock(name, dir = BLOCKS_DIR) {
|
|
101
|
+
return shippedBlocks(dir).find((block) => block.name === name) || null
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* What a file found in a plugin tree is, judged by its header and body
|
|
106
|
+
* alone: `unmodified` when a block header names a shipped block and the
|
|
107
|
+
* body's sha256 is that block's for that file; `modified` when it carries
|
|
108
|
+
* a block header but the body is not one this omakit ships; null when it
|
|
109
|
+
* has no block header at all.
|
|
110
|
+
*
|
|
111
|
+
* @param {string} file the base name, e.g. Run.qml
|
|
112
|
+
* @param {string} text
|
|
113
|
+
* @param {ReturnType<typeof shippedBlocks>} [blocks]
|
|
114
|
+
* An older shipped body (blocks/history.json) is unmodified at its own
|
|
115
|
+
* version, which the row names beside the shipped one.
|
|
116
|
+
*
|
|
117
|
+
* @returns {{ name: string, version: string, state: "unmodified"|"modified", shippedVersion: string|null } | null}
|
|
118
|
+
*/
|
|
119
|
+
export function recogniseBlockFile(file, text, blocks = shippedBlocks(), history = shippedHistory()) {
|
|
120
|
+
const parsed = parseHeader(text)
|
|
121
|
+
if (!parsed) return null
|
|
122
|
+
const shipped = blocks.find((block) => block.name === parsed.name)
|
|
123
|
+
const sha = bodySha256(parsed.body)
|
|
124
|
+
const known = shipped?.files.find((entry) => entry.file === file)
|
|
125
|
+
const older = !known || known.sha256 !== sha ? history.find((row) => row.block === parsed.name && row.file === file && row.sha256 === sha) : null
|
|
126
|
+
const state = known && known.sha256 === sha ? "unmodified" : older ? "unmodified" : "modified"
|
|
127
|
+
return { name: parsed.name, version: older ? older.version : parsed.version, state, shippedVersion: shipped ? shipped.version : null }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The header line that carries the body hash, rewritten to the given digest. */
|
|
131
|
+
export function withBodySha256(text, sha256) {
|
|
132
|
+
return String(text).replace(/^((?:\/\/|#) ?Body sha256: )[0-9a-f]{64}$/m, `$1${sha256}`)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The header's source line with the commit stamped in. */
|
|
136
|
+
export function withSourceCommit(text, commit) {
|
|
137
|
+
return String(text).replace(/^((?:\/\/|#) ?Source: omakit [^,\n]+, commit )\S+$/m, `$1${commit}`)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The NOTICE for a set of blocks in one `omakit/` directory: per file the
|
|
142
|
+
* block, version, licence, copyright, source path and commit, and the
|
|
143
|
+
* body sha256, so the review's vendored-code expectation (record the
|
|
144
|
+
* upstream, keep the licence, disclose modifications) is met by one file.
|
|
145
|
+
*
|
|
146
|
+
* @param {Array<{ name: string, version: string, files: Array<{ file: string, sha256: string }> }>} blocks
|
|
147
|
+
* @param {string} commit the omakit commit the files came from, or "unstamped"
|
|
148
|
+
*/
|
|
149
|
+
export function renderNotice(blocks, commit) {
|
|
150
|
+
const lines = [
|
|
151
|
+
"omakit blocks in this directory",
|
|
152
|
+
"",
|
|
153
|
+
"Each file below was copied from omakit (https://github.com/mtolhuys/omakit),",
|
|
154
|
+
"MIT licence, Copyright (c) 2026 Maarten Tolhuijs, by `omakit add`. The body",
|
|
155
|
+
"sha256 is over everything after the file's header line",
|
|
156
|
+
`\`${HEADER_END}\`; \`omakit inspect\` reports a file whose body differs as`,
|
|
157
|
+
"modified, and `omakit add --update` refuses to overwrite one. Modifications",
|
|
158
|
+
"are to be listed here by the plugin's author.",
|
|
159
|
+
"",
|
|
160
|
+
]
|
|
161
|
+
for (const block of blocks) {
|
|
162
|
+
lines.push(`block ${block.name} ${block.version}, from omakit commit ${commit}`)
|
|
163
|
+
for (const file of block.files) lines.push(` ${file.file} sha256 ${file.sha256}`)
|
|
164
|
+
lines.push("")
|
|
165
|
+
}
|
|
166
|
+
return `${lines.join("\n")}\n`.replace(/\n\n$/, "\n")
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Every body sha256 omakit ever shipped for a block file, from
|
|
171
|
+
* blocks/history.json (appended by tools/blocks/stamp.mjs at every block
|
|
172
|
+
* change, never pruned) plus the current files: what `omakit add --update`
|
|
173
|
+
* may replace, and what `inspect` names as an older unmodified copy.
|
|
174
|
+
*
|
|
175
|
+
* @returns {Array<{ block: string, version: string, file: string, sha256: string }>}
|
|
176
|
+
*/
|
|
177
|
+
export function shippedHistory(dir = BLOCKS_DIR) {
|
|
178
|
+
let history = []
|
|
179
|
+
try {
|
|
180
|
+
history = JSON.parse(readFileSync(join(dir, "history.json"), "utf8"))
|
|
181
|
+
} catch {
|
|
182
|
+
history = []
|
|
183
|
+
}
|
|
184
|
+
const rows = [...history]
|
|
185
|
+
for (const block of shippedBlocks(dir)) {
|
|
186
|
+
for (const entry of block.files) {
|
|
187
|
+
if (!rows.some((row) => row.block === block.name && row.file === entry.file && row.sha256 === entry.sha256)) rows.push({ block: block.name, version: block.version, file: entry.file, sha256: entry.sha256 })
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return rows
|
|
191
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Maintainer's tool: rewrite the `Body sha256` line of every shipped block
|
|
2
|
+
// file to its body's digest and regenerate blocks/<name>/NOTICE, so the
|
|
3
|
+
// header a plugin author reads and the hash inspect recognises are one
|
|
4
|
+
// number. Run after editing a block:
|
|
5
|
+
//
|
|
6
|
+
// node tools/blocks/stamp.mjs # writes; prints what changed
|
|
7
|
+
// node tools/blocks/stamp.mjs --check # exits 1 when a header is stale
|
|
8
|
+
//
|
|
9
|
+
// Writes only under this checkout's blocks/ directory, never into a
|
|
10
|
+
// plugin tree; tests/unit/self-containment.test.mjs holds it to that.
|
|
11
|
+
|
|
12
|
+
import { readFileSync, writeFileSync } from "node:fs"
|
|
13
|
+
import { join } from "node:path"
|
|
14
|
+
import { BLOCKS_DIR, renderNotice, shippedBlocks, shippedHistory, withBodySha256 } from "./registry.mjs"
|
|
15
|
+
|
|
16
|
+
const check = process.argv.includes("--check")
|
|
17
|
+
let stale = 0
|
|
18
|
+
for (const block of shippedBlocks()) {
|
|
19
|
+
for (const entry of block.files) {
|
|
20
|
+
const stamped = withBodySha256(entry.text, entry.sha256)
|
|
21
|
+
if (stamped !== entry.text) {
|
|
22
|
+
stale += 1
|
|
23
|
+
process.stdout.write(`${check ? "stale" : "stamped"} blocks/${block.name}/${entry.file} ${entry.sha256}\n`)
|
|
24
|
+
const blockFile = join(block.dir, entry.file)
|
|
25
|
+
if (!check) writeFileSync(blockFile, stamped)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const notice = renderNotice([block], "unstamped")
|
|
29
|
+
let current = null
|
|
30
|
+
try {
|
|
31
|
+
current = readFileSync(join(block.dir, "NOTICE"), "utf8")
|
|
32
|
+
} catch {
|
|
33
|
+
current = null
|
|
34
|
+
}
|
|
35
|
+
if (current !== notice) {
|
|
36
|
+
stale += 1
|
|
37
|
+
process.stdout.write(`${check ? "stale" : "written"} blocks/${block.name}/NOTICE\n`)
|
|
38
|
+
const blockFile = join(block.dir, "NOTICE")
|
|
39
|
+
if (!check) writeFileSync(blockFile, notice)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
// history.json: every (block, version, file, sha256) ever shipped, so
|
|
43
|
+
// `add --update` can tell an older copy from a modified one. Append-only.
|
|
44
|
+
{
|
|
45
|
+
const rows = shippedHistory()
|
|
46
|
+
const text = `${JSON.stringify(rows, null, 1)}\n`
|
|
47
|
+
let current = null
|
|
48
|
+
try {
|
|
49
|
+
current = readFileSync(join(BLOCKS_DIR, "history.json"), "utf8")
|
|
50
|
+
} catch {
|
|
51
|
+
current = null
|
|
52
|
+
}
|
|
53
|
+
if (current !== text) {
|
|
54
|
+
stale += 1
|
|
55
|
+
process.stdout.write(`${check ? "stale" : "written"} blocks/history.json (${rows.length} shipped file versions)\n`)
|
|
56
|
+
const blockFile = join(BLOCKS_DIR, "history.json")
|
|
57
|
+
if (!check) writeFileSync(blockFile, text)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (!stale) process.stdout.write("every block header carries its body's sha256 and every NOTICE is current\n")
|
|
61
|
+
process.exitCode = check && stale ? 1 : 0
|
|
@@ -11,8 +11,9 @@ import { resolve } from "node:path"
|
|
|
11
11
|
import { pathToFileURL } from "node:url"
|
|
12
12
|
|
|
13
13
|
const ARGV_FORMS = new Set(["array", "string", "computed"])
|
|
14
|
-
const DEADLINE_VIA = new Set(["timer-kill", "timeout-argv", "destruction", null])
|
|
15
|
-
const COLLECTORS = new Set(["StdioCollector", "SplitParser", "none", "unknown"])
|
|
14
|
+
const DEADLINE_VIA = new Set(["timer-kill", "timeout-argv", "destruction", "block-run", null])
|
|
15
|
+
const COLLECTORS = new Set(["StdioCollector", "SplitParser", "Run", "none", "unknown"])
|
|
16
|
+
const BLOCK_STATES = new Set(["unmodified", "modified"])
|
|
16
17
|
const CONTROLLED = new Set(["observed", "not-observed", "unknown"])
|
|
17
18
|
const DECLARED_IN = new Set(["qml", "shell"])
|
|
18
19
|
const FILE_KINDS = ["qml", "js", "shell", "python", "other"]
|
|
@@ -44,14 +45,18 @@ function processRow(row, at, problems) {
|
|
|
44
45
|
if (row.argvForm === "array" && Array.isArray(row.argv)) {
|
|
45
46
|
for (const entry of row.expressions || []) if (row.argv[entry.index] !== entry.text) problems.push(`${at}.expressions[${entry.index}] does not name the argv element it stands for`)
|
|
46
47
|
}
|
|
47
|
-
for (const key of ["running", "detached", "shellWrapper"]) if (!isBool(row[key])) problems.push(`${at}.${key} is not a boolean`)
|
|
48
|
+
for (const key of ["running", "detached", "shellWrapper", "closedEnvironment"]) if (!isBool(row[key])) problems.push(`${at}.${key} is not a boolean`)
|
|
49
|
+
if (row.closedEnvironment && row.declaredIn !== "shell") problems.push(`${at}.closedEnvironment is set on a site that is not a shell line`)
|
|
48
50
|
const deadline = row.deadline || {}
|
|
49
51
|
if (!isBool(deadline.observed)) problems.push(`${at}.deadline.observed is not a boolean`)
|
|
50
|
-
if (!DEADLINE_VIA.has(deadline.via)) problems.push(`${at}.deadline.via is not timer-kill, timeout-argv, destruction or null`)
|
|
52
|
+
if (!DEADLINE_VIA.has(deadline.via)) problems.push(`${at}.deadline.via is not timer-kill, timeout-argv, destruction, block-run or null`)
|
|
53
|
+
if ((deadline.via === "block-run") !== (row.block === "run")) problems.push(`${at}.deadline.via block-run and block: "run" go together`)
|
|
54
|
+
if (row.block === "run" && !nullOr(isString)(row.helper)) problems.push(`${at}.helper is neither a tree path nor null`)
|
|
55
|
+
if (row.block !== "run" && row.helper !== undefined) problems.push(`${at}.helper is only for a Run site`)
|
|
51
56
|
if (deadline.observed !== (deadline.via !== null)) problems.push(`${at}.deadline.observed disagrees with deadline.via`)
|
|
52
57
|
if (!nullOr(isInt)(deadline.ms)) problems.push(`${at}.deadline.ms is neither an integer nor null`)
|
|
53
58
|
const output = row.output || {}
|
|
54
|
-
if (!COLLECTORS.has(output.collector)) problems.push(`${at}.output.collector is not StdioCollector, SplitParser, none or unknown`)
|
|
59
|
+
if (!COLLECTORS.has(output.collector)) problems.push(`${at}.output.collector is not StdioCollector, SplitParser, Run, none or unknown`)
|
|
55
60
|
if (!isBool(output.capObserved)) problems.push(`${at}.output.capObserved is not a boolean`)
|
|
56
61
|
if (!nullOr(isString)(output.via)) problems.push(`${at}.output.via is neither a string nor null`)
|
|
57
62
|
if (output.capObserved !== (output.via !== null)) problems.push(`${at}.output.capObserved disagrees with output.via`)
|
|
@@ -83,6 +88,7 @@ function write(row, at, problems) {
|
|
|
83
88
|
if (row.canonicalPath === null && row.controlledDirectory !== "unknown") problems.push(`${at}.controlledDirectory is decided for a path that could not be read`)
|
|
84
89
|
if (!isBool(row.temp)) problems.push(`${at}.temp is not a boolean`)
|
|
85
90
|
if (!nullOr(isString)(row.mode)) problems.push(`${at}.mode is neither a string nor null`)
|
|
91
|
+
if ((row.via === "block-store") !== (row.block === "store")) problems.push(`${at}.via block-store and block: "store" go together`)
|
|
86
92
|
}
|
|
87
93
|
|
|
88
94
|
function fn(row, at, problems) {
|
|
@@ -169,7 +175,7 @@ export function validateInspectDocument(document, known = {}) {
|
|
|
169
175
|
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`)
|
|
170
176
|
const shares = size.sample?.heavyShares
|
|
171
177
|
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
|
|
178
|
+
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")
|
|
173
179
|
const scorable = Array.isArray(observed.functions) && observed.functions.length > 0
|
|
174
180
|
if (typeof size.heavyShare !== "number" || size.heavyShare < 0 || size.heavyShare > 1) problems.push("size.heavyShare is not a share from 0 to 1")
|
|
175
181
|
else if (Array.isArray(observed.functions) && size.thresholds) {
|
|
@@ -203,6 +209,31 @@ export function validateInspectDocument(document, known = {}) {
|
|
|
203
209
|
}
|
|
204
210
|
}
|
|
205
211
|
}
|
|
212
|
+
if (!Array.isArray(document.blocks)) problems.push("blocks is not a list")
|
|
213
|
+
else for (const [index, row] of document.blocks.entries()) {
|
|
214
|
+
const at = `blocks[${index}]`
|
|
215
|
+
if (!isString(row.name) || !/^[a-z][a-z0-9-]*$/.test(row.name)) problems.push(`${at}.name is not a block name`)
|
|
216
|
+
if (!isString(row.version)) problems.push(`${at}.version is not a string`)
|
|
217
|
+
if (!nullOr(isString)(row.shippedVersion)) problems.push(`${at}.shippedVersion is neither a string nor null`)
|
|
218
|
+
if (!BLOCK_STATES.has(row.state)) problems.push(`${at}.state is not unmodified or modified`)
|
|
219
|
+
if (!isBool(row.complete)) problems.push(`${at}.complete is not a boolean`)
|
|
220
|
+
if (!Array.isArray(row.files) || !row.files.length) problems.push(`${at}.files is not a non-empty list`)
|
|
221
|
+
else {
|
|
222
|
+
for (const [fileIndex, file] of row.files.entries()) {
|
|
223
|
+
if (!isString(file.path)) problems.push(`${at}.files[${fileIndex}].path is not a string`)
|
|
224
|
+
if (!BLOCK_STATES.has(file.state)) problems.push(`${at}.files[${fileIndex}].state is not unmodified or modified`)
|
|
225
|
+
if (!isString(file.version)) problems.push(`${at}.files[${fileIndex}].version is not a string`)
|
|
226
|
+
}
|
|
227
|
+
if ((row.state === "unmodified") !== row.files.every((file) => file.state === "unmodified")) problems.push(`${at}.state disagrees with its files`)
|
|
228
|
+
// An unmodified block's files were not read: no row of any kind at them.
|
|
229
|
+
if (row.state === "unmodified" && observed) {
|
|
230
|
+
const paths = new Set(row.files.map((file) => file.path))
|
|
231
|
+
for (const key of ["processes", "hosts", "writes", "timers", "functions"]) {
|
|
232
|
+
for (const fact of Array.isArray(observed[key]) ? observed[key] : []) if (paths.has(fact.file)) problems.push(`observed.${key} has a row at ${fact.file}:${fact.line}, a file of unmodified block ${row.name}`)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
206
237
|
if (!Array.isArray(document.patterns)) problems.push("patterns is not a list")
|
|
207
238
|
else for (const [index, row] of document.patterns.entries()) {
|
|
208
239
|
const at = `patterns[${index}]`
|
|
@@ -9,9 +9,32 @@
|
|
|
9
9
|
import { blankComments, closingBracket, lineOf } from "./text.mjs"
|
|
10
10
|
|
|
11
11
|
const JS_BRANCH = /\b(?:if|else if|for|while|do|switch|case|catch)\b|&&|\|\||\?[^.:]/g
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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*(?:#.*|[<>&|].*)?$/
|
|
15
38
|
const PY_BRANCH = /^\s*(?:if|elif|for|while|except|with)\b|\band\b|\bor\b/
|
|
16
39
|
|
|
17
40
|
/**
|
|
@@ -91,6 +114,105 @@ function jsFunctions(file) {
|
|
|
91
114
|
return rows
|
|
92
115
|
}
|
|
93
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
|
+
|
|
94
216
|
function shellFunctions(file) {
|
|
95
217
|
const lines = file.text.split("\n")
|
|
96
218
|
const rows = []
|
|
@@ -104,18 +226,37 @@ function shellFunctions(file) {
|
|
|
104
226
|
let depth = 0
|
|
105
227
|
let deepest = 0
|
|
106
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 = []
|
|
107
238
|
for (let at = index + 1; at < lines.length; at += 1) {
|
|
108
239
|
const line = lines[at]
|
|
109
|
-
if (
|
|
240
|
+
if (heredoc) {
|
|
241
|
+
if ((heredoc.strip ? line.replace(/^\t+/, "") : line) === heredoc.word) heredoc = null
|
|
110
242
|
end = at
|
|
111
|
-
|
|
243
|
+
continue
|
|
112
244
|
}
|
|
113
|
-
if (
|
|
114
|
-
|
|
115
|
-
|
|
245
|
+
if (!open.length && SHELL_END.test(line.trim()) && line.startsWith(indent) && line.match(/^\s*/)[0].length === indent.length) {
|
|
246
|
+
end = at
|
|
247
|
+
break
|
|
116
248
|
}
|
|
117
|
-
|
|
118
|
-
|
|
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
|
|
119
260
|
end = at
|
|
120
261
|
}
|
|
121
262
|
rows.push({ file: file.path, line: index + 1, name, kind: "function", lines: end - index + 1, depth: deepest, branches })
|
|
@@ -124,11 +265,63 @@ function shellFunctions(file) {
|
|
|
124
265
|
return rows
|
|
125
266
|
}
|
|
126
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
|
+
|
|
127
311
|
function pythonFunctions(file) {
|
|
128
312
|
const lines = file.text.split("\n")
|
|
129
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
|
+
}
|
|
130
322
|
for (let index = 0; index < lines.length; index += 1) {
|
|
131
|
-
|
|
323
|
+
if (quoted[index]) continue
|
|
324
|
+
const head = lines[index].match(PY_DEF)
|
|
132
325
|
if (!head) continue
|
|
133
326
|
const base = head[1].length
|
|
134
327
|
let end = index
|
|
@@ -140,18 +333,49 @@ function pythonFunctions(file) {
|
|
|
140
333
|
// PEP 8, 2 in some trees); 4 is assumed when there is no body line.
|
|
141
334
|
let unit = 4
|
|
142
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
|
|
143
353
|
for (let at = index + 1; at < lines.length; at += 1) {
|
|
144
354
|
const line = lines[at]
|
|
145
355
|
if (!line.trim()) continue
|
|
146
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
|
+
}
|
|
147
368
|
if (indent <= base) break
|
|
369
|
+
balance = Math.max(0, state.balance)
|
|
370
|
+
triple = state.triple
|
|
371
|
+
continued = state.continued
|
|
148
372
|
if (first) {
|
|
149
373
|
unit = indent - base
|
|
150
374
|
first = false
|
|
151
375
|
}
|
|
152
376
|
const level = Math.max(0, Math.floor((indent - base) / unit) - 1)
|
|
153
377
|
if (level > deepest) deepest = level
|
|
154
|
-
if (PY_BRANCH.test(
|
|
378
|
+
if (PY_BRANCH.test(state.code)) branches += 1
|
|
155
379
|
end = at
|
|
156
380
|
}
|
|
157
381
|
rows.push({ file: file.path, line: index + 1, name: head[2], kind: "function", lines: end - index + 1, depth: deepest, branches })
|