probatio 0.1.0 → 0.1.3

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.
@@ -0,0 +1,85 @@
1
+ // Cache-busts package files so the next mutant in this process loads the patched source.
2
+ // The generation lives in a file because the loader thread does not see later env edits.
3
+ import { createRequire } from "node:module"
4
+ import { readFileSync, realpathSync } from "node:fs"
5
+ import path from "node:path"
6
+ import { fileURLToPath } from "node:url"
7
+
8
+ const require = createRequire(import.meta.url)
9
+ const ts = require("typescript")
10
+
11
+ function generation() {
12
+ const file = process.env.PB_GEN
13
+ if (!file) return ""
14
+ try {
15
+ return readFileSync(file, "utf8").trim()
16
+ } catch {
17
+ return ""
18
+ }
19
+ }
20
+
21
+ function packageRoot() {
22
+ const root = process.env.PB_ROOT
23
+ if (!root) return ""
24
+ try {
25
+ return realpathSync(root)
26
+ } catch {
27
+ return root
28
+ }
29
+ }
30
+
31
+ function underPackage(abs) {
32
+ const root = packageRoot()
33
+ if (!root) return false
34
+ let real = abs
35
+ try {
36
+ real = realpathSync(abs)
37
+ } catch {
38
+ return false
39
+ }
40
+ return real === root || real.startsWith(root.endsWith(path.sep) ? root : root + path.sep)
41
+ }
42
+
43
+ export async function resolve(specifier, context, nextResolve) {
44
+ const resolved = await nextResolve(specifier, context)
45
+ const gen = generation()
46
+ if (!gen) return resolved
47
+ let abs
48
+ try {
49
+ abs = fileURLToPath(resolved.url.split("?")[0])
50
+ } catch {
51
+ return resolved
52
+ }
53
+ if (!underPackage(abs)) return resolved
54
+ if (abs.split(path.sep).includes("node_modules")) return resolved
55
+ const url = new URL(resolved.url.split("?")[0])
56
+ url.searchParams.set("pb", gen)
57
+ return { url: url.href, shortCircuit: true }
58
+ }
59
+
60
+ export async function load(url, context, nextLoad) {
61
+ if (!url.includes("pb=")) return nextLoad(url, context)
62
+ const clean = url.split("?")[0]
63
+ let abs
64
+ try {
65
+ abs = fileURLToPath(clean)
66
+ } catch {
67
+ return nextLoad(url, context)
68
+ }
69
+ const source = readFileSync(abs, "utf8")
70
+ if (abs.endsWith(".ts") || abs.endsWith(".tsx") || abs.endsWith(".mts")) {
71
+ const transpiled = ts.transpileModule(source, {
72
+ fileName: abs,
73
+ compilerOptions: {
74
+ module: ts.ModuleKind.ESNext,
75
+ target: ts.ScriptTarget.ES2022,
76
+ sourceMap: false,
77
+ },
78
+ })
79
+ return { format: "module", source: transpiled.outputText, shortCircuit: true }
80
+ }
81
+ if (abs.endsWith(".mjs") || abs.endsWith(".js") || source.includes("import ") || source.includes("export ")) {
82
+ return { format: "module", source, shortCircuit: true }
83
+ }
84
+ return { format: "commonjs", source, shortCircuit: true }
85
+ }
@@ -0,0 +1,130 @@
1
+ // One suite process for covered mutants of one file. Jobs arrive on stdin.
2
+ // A mutant that exits this process ends it. The parent starts a clean process for the next mutant.
3
+ import { createInterface } from "node:readline"
4
+ import { realpathSync, writeFileSync } from "node:fs"
5
+ import { register } from "node:module"
6
+ import { run } from "node:test"
7
+ import path from "node:path"
8
+ import { openLineSampler } from "./precise-lines.mjs"
9
+
10
+ const loader = new URL("./node-batch-loader.mjs", import.meta.url).href
11
+ register(loader, { parentURL: import.meta.url })
12
+
13
+ let sampler = null
14
+ try {
15
+ sampler = await openLineSampler()
16
+ } catch {
17
+ sampler = null
18
+ }
19
+
20
+ writeFileSync(process.env.PB_READY, `${JSON.stringify({ pid: process.pid })}\n`)
21
+
22
+ function escapeRegExp(value) {
23
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
24
+ }
25
+
26
+ let generation = 0
27
+
28
+ async function runJob(job) {
29
+ generation += 1
30
+ writeFileSync(process.env.PB_GEN, String(generation))
31
+ if (sampler) await sampler.reset()
32
+ const files = (job.tests || []).map((rel) => path.join(job.root, rel))
33
+ const patterns = (job.names || []).filter((name) => name.length > 0).map((name) => new RegExp(`^${escapeRegExp(name)}$`))
34
+ const failed = []
35
+ const names = []
36
+ const testTimeout = job.testTimeoutMs > 0 ? job.testTimeoutMs : job.timeoutMs
37
+ const stream = run({
38
+ files,
39
+ isolation: "none",
40
+ timeout: testTimeout,
41
+ ...(patterns.length > 0 ? { testNamePatterns: patterns } : {}),
42
+ })
43
+ let lastEvent = Date.now()
44
+ const started = Date.now()
45
+ let ended = false
46
+ let timer = null
47
+ const collected = (async () => {
48
+ for await (const event of stream) {
49
+ lastEvent = Date.now()
50
+ if (event.type !== "test:pass" && event.type !== "test:fail") continue
51
+ const data = event.data || {}
52
+ const name = typeof data.name === "string" ? data.name : ""
53
+ if (!name || files.includes(name)) continue
54
+ if (!names.includes(name)) names.push(name)
55
+ if (event.type === "test:fail") {
56
+ const error = data.details && data.details.error
57
+ const text = error ? `${error.failureType || ""} ${error.message || ""} ${error.cause || ""}` : ""
58
+ const fileTimeout = /testTimeoutFailure|timed out/i.test(text) && fileNameIs(name, data.file || "")
59
+ failed.push({ name, file: data.file || "", line: data.line || 0, ...(fileTimeout ? { fileTimeout: true } : {}) })
60
+ }
61
+ }
62
+ ended = true
63
+ })()
64
+ let dirty = false
65
+ try {
66
+ dirty = await Promise.race([
67
+ collected.then(() => false),
68
+ new Promise((resolve) => {
69
+ timer = setInterval(() => {
70
+ if (ended) {
71
+ resolve(false)
72
+ return
73
+ }
74
+ const idle = Date.now() - lastEvent
75
+ const overdue = Date.now() - started >= testTimeout && idle >= 400 && names.length > 0
76
+ if (!overdue) return
77
+ resolve(true)
78
+ }, 50)
79
+ timer.unref()
80
+ }),
81
+ ])
82
+ } finally {
83
+ if (timer) clearInterval(timer)
84
+ }
85
+ if (dirty && failed.length === 0) {
86
+ for (const rel of job.tests || []) {
87
+ const posix = rel.split(path.sep).join("/")
88
+ let file = path.join(job.root, rel)
89
+ try {
90
+ file = realpathSync(file)
91
+ } catch {
92
+ // The reporter uses the path it opened. A missing file keeps the joined path.
93
+ }
94
+ failed.push({ name: posix, file, line: 1, fileTimeout: true })
95
+ if (!names.includes(posix)) names.push(posix)
96
+ }
97
+ }
98
+ if (sampler && !dirty) await sampler.reset()
99
+ const pattern = (job.names || []).join("|")
100
+ const command = ["node", "--test", ...(pattern ? [`--test-name-pattern=${pattern}`] : []), ...(job.tests || [])].join(" ")
101
+ return { id: job.id, pid: process.pid, failed, names, command, dirty }
102
+ }
103
+
104
+ function fileNameIs(name, file) {
105
+ const base = String(file).split(/[/\\]/).pop()
106
+ if (!base) return false
107
+ return name === file || name === base || name.endsWith(`/${base}`) || name.endsWith(`\\${base}`)
108
+ }
109
+
110
+ const lines = createInterface({ input: process.stdin })
111
+ for await (const line of lines) {
112
+ if (!line.trim()) continue
113
+ const job = JSON.parse(line)
114
+ // node:test with isolation "none" waits until the event loop is idle.
115
+ // An open readline on stdin is that handle, so the job would never finish.
116
+ lines.pause()
117
+ process.stdin.unref()
118
+ try {
119
+ const reply = await runJob(job)
120
+ writeFileSync(job.replyFile, `${JSON.stringify(reply)}\n`)
121
+ // An open handle after the tests finished is the same file timeout node --test reports.
122
+ // The process is dirty, so it ends and the next mutant starts clean.
123
+ if (reply.dirty) process.exit(0)
124
+ } catch (err) {
125
+ writeFileSync(job.replyFile, `${JSON.stringify({ id: job.id, pid: process.pid, failed: [], names: [], error: err instanceof Error ? err.message : String(err) })}\n`)
126
+ } finally {
127
+ if (!process.stdin.destroyed) process.stdin.ref()
128
+ lines.resume()
129
+ }
130
+ }
@@ -1,200 +1,29 @@
1
1
  // Baseline-only per-test line map for node --test. Loaded with --import in each
2
2
  // test-file process. Writes one shard; the runner merges shards after the suite.
3
3
  // takePreciseCoverage resets counters, so each sample is only what ran since the last take.
4
+ // A plain-script child does not run these hooks. It may leave a nameless hit list in
5
+ // ${map}.children. afterEach stores that list under the parent test before the shard is written.
4
6
  import { randomBytes } from "node:crypto"
5
- import { mkdirSync, writeFileSync } from "node:fs"
6
- import { Session } from "node:inspector/promises"
7
- import { findSourceMap } from "node:module"
7
+ import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"
8
8
  import { after, afterEach, before } from "node:test"
9
9
  import path from "node:path"
10
- import { fileURLToPath } from "node:url"
11
-
12
- const B64 = new Int32Array(128)
13
- for (let i = 0; i < 64; i++) B64["ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".charCodeAt(i)] = i
14
-
15
- function decodeSegments(row) {
16
- const segments = []
17
- let i = 0
18
- while (i < row.length) {
19
- const fields = []
20
- while (i < row.length && row.charCodeAt(i) !== 44) {
21
- let value = 0
22
- let shift = 0
23
- let digit = 0
24
- do {
25
- digit = B64[row.charCodeAt(i++)]
26
- value |= (digit & 31) << shift
27
- shift += 5
28
- } while (digit & 32)
29
- fields.push(value & 1 ? -(value >> 1) : value >> 1)
30
- }
31
- if (row.charCodeAt(i) === 44) i++
32
- if (fields.length > 0) segments.push(fields)
33
- }
34
- return segments
35
- }
10
+ import { openLineSampler } from "./precise-lines.mjs"
36
11
 
37
12
  const mapPath = process.env.PROBATIO_COVERAGE_MAP
38
13
  if (mapPath) {
39
- const session = new Session()
40
- let started = false
14
+ let sampler = null
41
15
  try {
42
- session.connect()
43
- await session.post("Profiler.enable")
44
- await session.post("Debugger.enable")
45
- await session.post("Profiler.startPreciseCoverage", { callCount: true, detailed: true })
46
- started = true
16
+ sampler = await openLineSampler((rel) => rel.endsWith("node-coverage.mjs") || rel.endsWith("precise-lines.mjs") || rel.endsWith("child-lines.mjs"))
47
17
  } catch (err) {
48
18
  writeFileSync(`${mapPath}.error`, `${err instanceof Error ? err.message : String(err)}\n`)
49
19
  }
50
20
 
51
- if (started) {
52
- const textCache = new Map()
53
- const indexCache = new Map()
21
+ if (sampler) {
54
22
  /** @type {Map<string, Map<number, Set<string>>>} */
55
23
  const byFile = new Map()
56
24
  /** @type {Array<{ file: string, line: number }>} */
57
25
  let prelude = []
58
26
 
59
- async function scriptText(script) {
60
- if (textCache.has(script.scriptId)) return textCache.get(script.scriptId)
61
- let text = ""
62
- try {
63
- const got = await session.post("Debugger.getScriptSource", { scriptId: script.scriptId })
64
- text = got.scriptSource || ""
65
- } catch {
66
- text = ""
67
- }
68
- textCache.set(script.scriptId, text)
69
- return text
70
- }
71
-
72
- function relPath(url) {
73
- if (!url || url.startsWith("node:")) return null
74
- let abs
75
- try {
76
- abs = fileURLToPath(url.split("?")[0].split("#")[0])
77
- } catch {
78
- return null
79
- }
80
- const rel = path.relative(process.cwd(), abs).split(path.sep).join("/")
81
- if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) return null
82
- if (rel.split("/").includes("node_modules")) return null
83
- if (rel.endsWith("node-coverage.mjs")) return null
84
- return rel
85
- }
86
-
87
- function indexFor(scriptId, text, sm) {
88
- if (indexCache.has(scriptId)) return indexCache.get(scriptId)
89
- const mapped = sm && sm.payload && typeof sm.payload.mappings === "string" ? linesFromMappings(text, sm.payload.mappings) : null
90
- const lines = mapped && hasLine(mapped) ? mapped : linesFromNewlines(text, sm)
91
- indexCache.set(scriptId, lines)
92
- return lines
93
- }
94
-
95
- function hasLine(lines) {
96
- for (let i = 0; i < lines.length; i++) if (lines[i] > 0) return true
97
- return false
98
- }
99
-
100
- function linesFromNewlines(text, sm) {
101
- const lines = new Int32Array(text.length + 1)
102
- if (!sm) {
103
- let line = 1
104
- for (let i = 0; i < text.length; i++) {
105
- lines[i] = line
106
- if (text.charCodeAt(i) === 10) line++
107
- }
108
- return lines
109
- }
110
- // Node 22 reports SourceMap.findEntry originalLine as 0-based.
111
- let genLine = 0
112
- let col = 0
113
- for (let i = 0; i < text.length; i++) {
114
- const entry = sm.findEntry(genLine, col)
115
- if (entry && Number.isInteger(entry.originalLine) && entry.originalLine >= 0) lines[i] = entry.originalLine + 1
116
- if (text.charCodeAt(i) === 10) {
117
- genLine++
118
- col = 0
119
- } else {
120
- col++
121
- }
122
- }
123
- return lines
124
- }
125
-
126
- function linesFromMappings(text, mappings) {
127
- const lines = new Int32Array(text.length + 1)
128
- const starts = [0]
129
- for (let i = 0; i < text.length; i++) if (text.charCodeAt(i) === 10) starts.push(i + 1)
130
- let carryLine = 0
131
- const rows = mappings.split(";")
132
- for (let genLine = 0; genLine < rows.length && genLine < starts.length; genLine++) {
133
- const start = starts[genLine]
134
- const next = genLine + 1 < starts.length ? starts[genLine + 1] : text.length
135
- let col = 0
136
- let cursor = start
137
- let active = -1
138
- for (const fields of decodeSegments(rows[genLine])) {
139
- col += fields[0]
140
- const at = Math.min(next, start + Math.max(0, col))
141
- if (active >= 0) {
142
- for (let i = cursor; i < at; i++) lines[i] = active + 1
143
- }
144
- cursor = at
145
- if (fields.length >= 4) {
146
- carryLine += fields[2]
147
- active = carryLine
148
- } else {
149
- active = -1
150
- }
151
- }
152
- if (active >= 0) {
153
- for (let i = cursor; i < next; i++) lines[i] = active + 1
154
- }
155
- }
156
- return lines
157
- }
158
-
159
- function applyRanges(text, script, lines) {
160
- const ranges = []
161
- for (const fn of script.functions || []) {
162
- for (const range of fn.ranges || []) ranges.push(range)
163
- }
164
- // Outer ranges first. A nested block then overwrites its own offsets, so a dead
165
- // branch stays at 0 while a condition earlier on that same line can stay covered.
166
- ranges.sort((a, b) => a.startOffset - b.startOffset || b.endOffset - a.endOffset)
167
- const offsets = new Int32Array(text.length)
168
- for (const range of ranges) {
169
- const end = Math.min(text.length, range.endOffset)
170
- for (let i = Math.max(0, range.startOffset); i < end; i++) offsets[i] = range.count
171
- }
172
- const counts = new Map()
173
- for (let i = 0; i < text.length; i++) {
174
- const line = lines[i]
175
- if (line <= 0 || offsets[i] <= 0) continue
176
- if (offsets[i] > (counts.get(line) || 0)) counts.set(line, offsets[i])
177
- }
178
- return counts
179
- }
180
-
181
- async function positiveLines() {
182
- const cov = await session.post("Profiler.takePreciseCoverage")
183
- /** @type {Array<{ file: string, line: number }>} */
184
- const found = []
185
- for (const script of cov.result || []) {
186
- const file = relPath(script.url)
187
- if (!file) continue
188
- const text = await scriptText(script)
189
- if (!text) continue
190
- const counts = applyRanges(text, script, indexFor(script.scriptId, text, findSourceMap(script.url)))
191
- for (const [line, count] of counts) {
192
- if (count > 0) found.push({ file, line })
193
- }
194
- }
195
- return found
196
- }
197
-
198
27
  function add(hits, name) {
199
28
  if (!name) return
200
29
  for (const hit of hits) {
@@ -212,9 +41,36 @@ if (mapPath) {
212
41
  }
213
42
  }
214
43
 
44
+ function takeChildHits() {
45
+ const dir = `${mapPath}.children`
46
+ if (!existsSync(dir)) return []
47
+ /** @type {Array<{ file: string, line: number }>} */
48
+ const hits = []
49
+ for (const name of readdirSync(dir)) {
50
+ if (!name.endsWith(".json")) continue
51
+ const full = path.join(dir, name)
52
+ try {
53
+ const parsed = JSON.parse(readFileSync(full, "utf8"))
54
+ const list = parsed && Array.isArray(parsed.hits) ? parsed.hits : []
55
+ for (const hit of list) {
56
+ if (!hit || typeof hit.file !== "string" || !Number.isInteger(hit.line)) continue
57
+ hits.push({ file: hit.file, line: hit.line })
58
+ }
59
+ } catch {
60
+ // A torn dump is ignored. The parent does not invent a line.
61
+ }
62
+ try {
63
+ unlinkSync(full)
64
+ } catch {
65
+ // The next test must not inherit this dump.
66
+ }
67
+ }
68
+ return hits
69
+ }
70
+
215
71
  before(async () => {
216
72
  try {
217
- prelude = await positiveLines()
73
+ prelude = await sampler.positiveLines()
218
74
  } catch (err) {
219
75
  writeFileSync(`${mapPath}.error`, `${err instanceof Error ? err.message : String(err)}\n`)
220
76
  }
@@ -223,7 +79,8 @@ if (mapPath) {
223
79
  afterEach(async (t) => {
224
80
  try {
225
81
  add(prelude, t.name)
226
- add(await positiveLines(), t.name)
82
+ add(await sampler.positiveLines(), t.name)
83
+ add(takeChildHits(), t.name)
227
84
  } catch (err) {
228
85
  writeFileSync(`${mapPath}.error`, `${err instanceof Error ? err.message : String(err)}\n`)
229
86
  }
@@ -0,0 +1,188 @@
1
+ // Inspector line samples shared by the node:test collector, the Mocha collector,
2
+ // and a plain-script child. This file has no test hooks and writes no shard.
3
+ import { Session } from "node:inspector/promises"
4
+ import { findSourceMap } from "node:module"
5
+ import path from "node:path"
6
+ import { fileURLToPath } from "node:url"
7
+
8
+ const B64 = new Int32Array(128)
9
+ for (let i = 0; i < 64; i++) B64["ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".charCodeAt(i)] = i
10
+
11
+ function decodeSegments(row) {
12
+ const segments = []
13
+ let i = 0
14
+ while (i < row.length) {
15
+ const fields = []
16
+ while (i < row.length && row.charCodeAt(i) !== 44) {
17
+ let value = 0
18
+ let shift = 0
19
+ let digit = 0
20
+ do {
21
+ digit = B64[row.charCodeAt(i++)]
22
+ value |= (digit & 31) << shift
23
+ shift += 5
24
+ } while (digit & 32)
25
+ fields.push(value & 1 ? -(value >> 1) : value >> 1)
26
+ }
27
+ if (row.charCodeAt(i) === 44) i++
28
+ if (fields.length > 0) segments.push(fields)
29
+ }
30
+ return segments
31
+ }
32
+
33
+ /**
34
+ * @param {(rel: string) => boolean} [skipRel]
35
+ * @returns {Promise<{ positiveLines: () => Promise<Array<{ file: string, line: number }>>, reset: () => Promise<void> }>}
36
+ */
37
+ export async function openLineSampler(skipRel) {
38
+ const session = new Session()
39
+ session.connect()
40
+ await session.post("Profiler.enable")
41
+ await session.post("Debugger.enable")
42
+ await session.post("Profiler.startPreciseCoverage", { callCount: true, detailed: true })
43
+
44
+ const textCache = new Map()
45
+ const indexCache = new Map()
46
+
47
+ async function scriptText(script) {
48
+ if (textCache.has(script.scriptId)) return textCache.get(script.scriptId)
49
+ let text = ""
50
+ try {
51
+ const got = await session.post("Debugger.getScriptSource", { scriptId: script.scriptId })
52
+ text = got.scriptSource || ""
53
+ } catch {
54
+ text = ""
55
+ }
56
+ textCache.set(script.scriptId, text)
57
+ return text
58
+ }
59
+
60
+ function relPath(url) {
61
+ if (!url || url.startsWith("node:")) return null
62
+ let abs
63
+ try {
64
+ abs = fileURLToPath(url.split("?")[0].split("#")[0])
65
+ } catch {
66
+ return null
67
+ }
68
+ const rel = path.relative(process.cwd(), abs).split(path.sep).join("/")
69
+ if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) return null
70
+ if (rel.split("/").includes("node_modules")) return null
71
+ if (skipRel && skipRel(rel)) return null
72
+ return rel
73
+ }
74
+
75
+ function indexFor(scriptId, text, sm) {
76
+ if (indexCache.has(scriptId)) return indexCache.get(scriptId)
77
+ const mapped = sm && sm.payload && typeof sm.payload.mappings === "string" ? linesFromMappings(text, sm.payload.mappings) : null
78
+ const lines = mapped && hasLine(mapped) ? mapped : linesFromNewlines(text, sm)
79
+ indexCache.set(scriptId, lines)
80
+ return lines
81
+ }
82
+
83
+ function hasLine(lines) {
84
+ for (let i = 0; i < lines.length; i++) if (lines[i] > 0) return true
85
+ return false
86
+ }
87
+
88
+ function linesFromNewlines(text, sm) {
89
+ const lines = new Int32Array(text.length + 1)
90
+ if (!sm) {
91
+ let line = 1
92
+ for (let i = 0; i < text.length; i++) {
93
+ lines[i] = line
94
+ if (text.charCodeAt(i) === 10) line++
95
+ }
96
+ return lines
97
+ }
98
+ let genLine = 0
99
+ let col = 0
100
+ for (let i = 0; i < text.length; i++) {
101
+ const entry = sm.findEntry(genLine, col)
102
+ if (entry && Number.isInteger(entry.originalLine) && entry.originalLine >= 0) lines[i] = entry.originalLine + 1
103
+ if (text.charCodeAt(i) === 10) {
104
+ genLine++
105
+ col = 0
106
+ } else {
107
+ col++
108
+ }
109
+ }
110
+ return lines
111
+ }
112
+
113
+ function linesFromMappings(text, mappings) {
114
+ const lines = new Int32Array(text.length + 1)
115
+ const starts = [0]
116
+ for (let i = 0; i < text.length; i++) if (text.charCodeAt(i) === 10) starts.push(i + 1)
117
+ let carryLine = 0
118
+ const rows = mappings.split(";")
119
+ for (let genLine = 0; genLine < rows.length && genLine < starts.length; genLine++) {
120
+ const start = starts[genLine]
121
+ const next = genLine + 1 < starts.length ? starts[genLine + 1] : text.length
122
+ let col = 0
123
+ let cursor = start
124
+ let active = -1
125
+ for (const fields of decodeSegments(rows[genLine])) {
126
+ col += fields[0]
127
+ const at = Math.min(next, start + Math.max(0, col))
128
+ if (active >= 0) {
129
+ for (let i = cursor; i < at; i++) lines[i] = active + 1
130
+ }
131
+ cursor = at
132
+ if (fields.length >= 4) {
133
+ carryLine += fields[2]
134
+ active = carryLine
135
+ } else {
136
+ active = -1
137
+ }
138
+ }
139
+ if (active >= 0) {
140
+ for (let i = cursor; i < next; i++) lines[i] = active + 1
141
+ }
142
+ }
143
+ return lines
144
+ }
145
+
146
+ function applyRanges(text, script, lines) {
147
+ const ranges = []
148
+ for (const fn of script.functions || []) {
149
+ for (const range of fn.ranges || []) ranges.push(range)
150
+ }
151
+ ranges.sort((a, b) => a.startOffset - b.startOffset || b.endOffset - a.endOffset)
152
+ const offsets = new Int32Array(text.length)
153
+ for (const range of ranges) {
154
+ const end = Math.min(text.length, range.endOffset)
155
+ for (let i = Math.max(0, range.startOffset); i < end; i++) offsets[i] = range.count
156
+ }
157
+ const counts = new Map()
158
+ for (let i = 0; i < text.length; i++) {
159
+ const line = lines[i]
160
+ if (line <= 0 || offsets[i] <= 0) continue
161
+ if (offsets[i] > (counts.get(line) || 0)) counts.set(line, offsets[i])
162
+ }
163
+ return counts
164
+ }
165
+
166
+ async function positiveLines() {
167
+ const cov = await session.post("Profiler.takePreciseCoverage")
168
+ const found = []
169
+ for (const script of cov.result || []) {
170
+ const file = relPath(script.url)
171
+ if (!file) continue
172
+ const text = await scriptText(script)
173
+ if (!text) continue
174
+ const counts = applyRanges(text, script, indexFor(script.scriptId, text, findSourceMap(script.url)))
175
+ for (const [line, count] of counts) {
176
+ if (count > 0) found.push({ file, line })
177
+ }
178
+ }
179
+ return found
180
+ }
181
+
182
+ return {
183
+ positiveLines,
184
+ reset: async () => {
185
+ await session.post("Profiler.takePreciseCoverage")
186
+ },
187
+ }
188
+ }