probatio 0.0.0-stage → 0.0.2

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,188 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ const JACOCO = "0.8.15";
6
+ const ASM = "9.10.1";
7
+ const RUNNER = fileURLToPath(new URL("./probatio-jacoco-run.java", import.meta.url));
8
+ /**
9
+ * One JVM, one JaCoCo dump per Surefire test method.
10
+ * The default jacoco.exec file does not name the test.
11
+ */
12
+ export function writeJavaCoverage(pkg, testNames, dest) {
13
+ const tests = testNames.map(junitMethod).filter((name) => name !== null);
14
+ if (tests.length === 0)
15
+ return { ok: false, detail: "java coverage: no class.method test names" };
16
+ const classDirs = findClassDirs(pkg);
17
+ if (classDirs.length === 0)
18
+ return { ok: false, detail: "java coverage: no target/classes" };
19
+ const work = path.join(path.dirname(dest), "java-cover");
20
+ mkdirSync(work, { recursive: true });
21
+ const jars = resolveJars(pkg, work);
22
+ if ("error" in jars)
23
+ return { ok: false, detail: jars.error };
24
+ const source = path.join(work, "ProbatioJacocoRun.java");
25
+ writeFileSync(source, readFileSync(RUNNER));
26
+ const compileCp = [jars.agent, jars.junit, jars.core, jars.asm, jars.asmCommons, jars.asmTree].join(path.delimiter);
27
+ const compiled = run(javaTool("javac"), ["-encoding", "UTF-8", "-cp", compileCp, "-d", work, source], pkg);
28
+ if (compiled.status !== 0)
29
+ return { ok: false, detail: clip(compiled.stderr || compiled.stdout || compiled.error?.message || "javac failed") };
30
+ const testsFile = path.join(work, "tests.txt");
31
+ writeFileSync(testsFile, `${tests.join("\n")}\n`);
32
+ const projectCp = buildClasspath(pkg, work);
33
+ if ("error" in projectCp)
34
+ return { ok: false, detail: projectCp.error };
35
+ // Our asm and JaCoCo must win over a project that still ships an older asm. That older reader rejects Java 27 class files.
36
+ const cp = [work, jars.core, jars.asm, jars.asmCommons, jars.asmTree, jars.junit, ...classDirs, ...testClassDirs(pkg), projectCp.cp].filter(Boolean).join(path.delimiter);
37
+ const dump = path.join(work, "hits.txt");
38
+ const ran = run(javaTool("java"), [`-javaagent:${jars.agent}=output=none,excludes=ProbatioJacocoRun`, "-cp", cp, "ProbatioJacocoRun", classDirs.join(path.delimiter), testsFile, dump, pkg], pkg);
39
+ if (ran.error && ran.error.code === "ETIMEDOUT") {
40
+ return { ok: false, detail: "java coverage: timed out" };
41
+ }
42
+ if (ran.status !== 0 || !existsSync(dump)) {
43
+ return { ok: false, detail: clip(ran.stderr || ran.stdout || ran.error?.message || "java coverage runner failed") };
44
+ }
45
+ const files = hitsToMap(readFileSync(dump, "utf8"));
46
+ if (Object.keys(files).length === 0)
47
+ return { ok: false, detail: "java coverage: no covered lines" };
48
+ writeFileSync(dest, `${JSON.stringify({ files }, null, 2)}\n`);
49
+ return { ok: true, detail: `${Object.keys(files).length} files` };
50
+ }
51
+ export function hitsToMap(text) {
52
+ const files = {};
53
+ for (const raw of text.split("\n")) {
54
+ const line = raw.trim();
55
+ if (!line.startsWith("HIT "))
56
+ continue;
57
+ const parts = line.slice(4).split(" ");
58
+ if (parts.length < 3)
59
+ continue;
60
+ const test = parts.pop() ?? "";
61
+ const number = parts.pop() ?? "";
62
+ const file = parts.join(" ");
63
+ if (!file || !/^\d+$/.test(number) || !test)
64
+ continue;
65
+ const lines = files[file] ?? {};
66
+ const names = lines[number] ?? [];
67
+ if (!names.includes(test))
68
+ names.push(test);
69
+ lines[number] = names;
70
+ files[file] = lines;
71
+ }
72
+ return files;
73
+ }
74
+ function junitMethod(name) {
75
+ const leaf = name.split("::").pop() ?? name;
76
+ const dot = leaf.lastIndexOf(".");
77
+ if (dot <= 0 || dot === leaf.length - 1)
78
+ return null;
79
+ if (leaf.startsWith("maven-") || leaf === "javac")
80
+ return null;
81
+ return `${leaf.slice(0, dot)}#${leaf.slice(dot + 1)}`;
82
+ }
83
+ function findClassDirs(root) {
84
+ const found = [];
85
+ const walk = (dir, depth) => {
86
+ if (depth > 4)
87
+ return;
88
+ let entries = [];
89
+ try {
90
+ entries = readdirSync(dir);
91
+ }
92
+ catch {
93
+ return;
94
+ }
95
+ for (const name of entries) {
96
+ if (name === "node_modules" || name === ".git" || name === "java-cover")
97
+ continue;
98
+ const full = path.join(dir, name);
99
+ if (name === "target") {
100
+ const classes = path.join(full, "classes");
101
+ if (existsSync(classes))
102
+ found.push(classes);
103
+ continue;
104
+ }
105
+ if (name.startsWith("."))
106
+ continue;
107
+ walk(full, depth + 1);
108
+ }
109
+ };
110
+ walk(root, 0);
111
+ return found;
112
+ }
113
+ function testClassDirs(root) {
114
+ return findClassDirs(root).map((dir) => path.join(path.dirname(dir), "test-classes")).filter((dir) => existsSync(dir));
115
+ }
116
+ function resolveJars(pkg, work) {
117
+ const copies = [
118
+ [`org.jacoco:org.jacoco.agent:${JACOCO}:jar:runtime`, "agent.jar"],
119
+ [`org.jacoco:org.jacoco.core:${JACOCO}`, "core.jar"],
120
+ [`org.ow2.asm:asm:${ASM}`, "asm.jar"],
121
+ [`org.ow2.asm:asm-commons:${ASM}`, "asm-commons.jar"],
122
+ [`org.ow2.asm:asm-tree:${ASM}`, "asm-tree.jar"],
123
+ ["junit:junit:4.13.2", "junit.jar"],
124
+ ];
125
+ const paths = {};
126
+ for (const [artifact, name] of copies) {
127
+ const dest = path.join(work, name);
128
+ if (!existsSync(dest)) {
129
+ const before = new Set(readdirSync(work));
130
+ const copied = run("mvn", ["-B", "-q", "org.apache.maven.plugins:maven-dependency-plugin:3.6.1:copy", `-Dartifact=${artifact}`, `-DoutputDirectory=${work}`], pkg);
131
+ if (copied.status !== 0)
132
+ return { error: clip(copied.stderr || copied.stdout || copied.error?.message || `mvn copy ${artifact} failed`) };
133
+ const created = readdirSync(work).find((item) => !before.has(item) && item.endsWith(".jar"));
134
+ if (!created)
135
+ return { error: `java coverage: missing ${artifact}` };
136
+ writeFileSync(dest, readFileSync(path.join(work, created)));
137
+ }
138
+ paths[name] = dest;
139
+ }
140
+ return {
141
+ agent: paths["agent.jar"],
142
+ core: paths["core.jar"],
143
+ asm: paths["asm.jar"],
144
+ asmCommons: paths["asm-commons.jar"],
145
+ asmTree: paths["asm-tree.jar"],
146
+ junit: paths["junit.jar"],
147
+ };
148
+ }
149
+ function buildClasspath(pkg, work) {
150
+ const file = path.join(work, "classpath.txt");
151
+ const built = run("mvn", ["-B", "-q", "dependency:build-classpath", "-DincludeScope=test", `-Dmdep.outputFile=${file}`], pkg);
152
+ if (built.status !== 0)
153
+ return { error: clip(built.stderr || built.stdout || built.error?.message || "mvn classpath failed") };
154
+ try {
155
+ return { cp: readFileSync(file, "utf8").trim() };
156
+ }
157
+ catch {
158
+ return { error: "java coverage: classpath file missing" };
159
+ }
160
+ }
161
+ function javaTool(name) {
162
+ const home = process.env.JAVA_HOME;
163
+ if (home) {
164
+ const candidate = path.join(home, "bin", name);
165
+ if (existsSync(candidate) && toolWorks(candidate))
166
+ return candidate;
167
+ }
168
+ if (toolWorks(name))
169
+ return name;
170
+ for (const dir of ["/opt/homebrew/opt/openjdk/bin", "/usr/local/opt/openjdk/bin"]) {
171
+ const candidate = path.join(dir, name);
172
+ if (existsSync(candidate) && toolWorks(candidate))
173
+ return candidate;
174
+ }
175
+ return name;
176
+ }
177
+ function toolWorks(bin) {
178
+ const probed = spawnSync(bin, ["-version"], { encoding: "utf8" });
179
+ const text = `${probed.stdout ?? ""}${probed.stderr ?? ""}`;
180
+ return probed.status === 0 && !/Unable to locate a Java Runtime/i.test(text);
181
+ }
182
+ function run(bin, args, cwd) {
183
+ return spawnSync(bin, args, { cwd, encoding: "utf8", timeout: 180_000 });
184
+ }
185
+ function clip(text) {
186
+ const line = text.trim().split("\n").find((item) => item.trim()) ?? "java coverage failed";
187
+ return line.slice(0, 240);
188
+ }
@@ -0,0 +1,34 @@
1
+ /** Names that mean the process failed to compile. These are not test titles. */
2
+ const BUILD_NAMES = new Set([
3
+ "build",
4
+ "javac",
5
+ "compiler",
6
+ "cobc",
7
+ "nasm",
8
+ "clang",
9
+ "cargo",
10
+ "tsc",
11
+ "dotnet",
12
+ "swiftc",
13
+ ]);
14
+ /**
15
+ * A named test and a compile failure are different facts.
16
+ * The build next step says the build failed and does not ask for a test
17
+ * named after the compiler.
18
+ */
19
+ export function killLabel(killedBy) {
20
+ if (killedBy.length === 0)
21
+ return null;
22
+ if (killedBy.every((item) => BUILD_NAMES.has(bareName(item)))) {
23
+ return { cause: "build", next: "Killed because the build failed. Record it as a compile kill." };
24
+ }
25
+ return { cause: "test", next: `Killed by ${killedBy.join(", ")}.` };
26
+ }
27
+ /** True when the id names a compiler, not a test. */
28
+ export function isBuildName(id) {
29
+ return BUILD_NAMES.has(bareName(id));
30
+ }
31
+ function bareName(id) {
32
+ const mark = id.lastIndexOf("::");
33
+ return (mark >= 0 ? id.slice(mark + 2) : id).trim().toLowerCase();
34
+ }
@@ -0,0 +1,245 @@
1
+ // Baseline-only per-test line map for node --test. Loaded with --import in each
2
+ // test-file process. Writes one shard; the runner merges shards after the suite.
3
+ // takePreciseCoverage resets counters, so each sample is only what ran since the last take.
4
+ 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"
8
+ import { after, afterEach, before } from "node:test"
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
+ }
36
+
37
+ const mapPath = process.env.PROBATIO_COVERAGE_MAP
38
+ if (mapPath) {
39
+ const session = new Session()
40
+ let started = false
41
+ 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
47
+ } catch (err) {
48
+ writeFileSync(`${mapPath}.error`, `${err instanceof Error ? err.message : String(err)}\n`)
49
+ }
50
+
51
+ if (started) {
52
+ const textCache = new Map()
53
+ const indexCache = new Map()
54
+ /** @type {Map<string, Map<number, Set<string>>>} */
55
+ const byFile = new Map()
56
+ /** @type {Array<{ file: string, line: number }>} */
57
+ let prelude = []
58
+
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
+ function add(hits, name) {
199
+ if (!name) return
200
+ for (const hit of hits) {
201
+ let lines = byFile.get(hit.file)
202
+ if (!lines) {
203
+ lines = new Map()
204
+ byFile.set(hit.file, lines)
205
+ }
206
+ let names = lines.get(hit.line)
207
+ if (!names) {
208
+ names = new Set()
209
+ lines.set(hit.line, names)
210
+ }
211
+ names.add(name)
212
+ }
213
+ }
214
+
215
+ before(async () => {
216
+ try {
217
+ prelude = await positiveLines()
218
+ } catch (err) {
219
+ writeFileSync(`${mapPath}.error`, `${err instanceof Error ? err.message : String(err)}\n`)
220
+ }
221
+ })
222
+
223
+ afterEach(async (t) => {
224
+ try {
225
+ add(prelude, t.name)
226
+ add(await positiveLines(), t.name)
227
+ } catch (err) {
228
+ writeFileSync(`${mapPath}.error`, `${err instanceof Error ? err.message : String(err)}\n`)
229
+ }
230
+ })
231
+
232
+ after(() => {
233
+ if (byFile.size === 0) return
234
+ const files = {}
235
+ for (const [file, lines] of byFile) {
236
+ const bucket = {}
237
+ for (const [line, names] of lines) bucket[String(line)] = [...names].sort()
238
+ files[file] = bucket
239
+ }
240
+ const dir = `${mapPath}.parts`
241
+ mkdirSync(dir, { recursive: true })
242
+ writeFileSync(path.join(dir, `${process.pid}-${randomBytes(4).toString("hex")}.json`), JSON.stringify({ files }))
243
+ })
244
+ }
245
+ }
@@ -0,0 +1,122 @@
1
+ import ts from "typescript";
2
+ const SWAP = {
3
+ [ts.SyntaxKind.AmpersandAmpersandToken]: { op: "and-to-or", replacement: "||" },
4
+ [ts.SyntaxKind.BarBarToken]: { op: "or-to-and", replacement: "&&" },
5
+ [ts.SyntaxKind.EqualsEqualsEqualsToken]: { op: "eq-to-neq", replacement: "!==" },
6
+ [ts.SyntaxKind.ExclamationEqualsEqualsToken]: { op: "neq-to-eq", replacement: "===" },
7
+ [ts.SyntaxKind.LessThanToken]: { op: "lt-to-le", replacement: "<=" },
8
+ [ts.SyntaxKind.LessThanEqualsToken]: { op: "le-to-lt", replacement: "<" },
9
+ [ts.SyntaxKind.GreaterThanToken]: { op: "gt-to-ge", replacement: ">=" },
10
+ [ts.SyntaxKind.GreaterThanEqualsToken]: { op: "ge-to-gt", replacement: ">" },
11
+ };
12
+ /**
13
+ * Operator mutants in code only. Strings, comments, and type positions are not code.
14
+ * A condition that contains a string (`name === "x"`) is still code: the span is not
15
+ * itself inside the string.
16
+ */
17
+ export function findMutants(file, text) {
18
+ const scriptKind = file.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS;
19
+ const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, scriptKind);
20
+ const raw = [];
21
+ const visit = (node) => {
22
+ if (isTypePosition(node))
23
+ return;
24
+ const point = pointAt(source, file, text, node);
25
+ if (point)
26
+ raw.push(point);
27
+ ts.forEachChild(node, visit);
28
+ };
29
+ visit(source);
30
+ const hidden = hiddenSpans(source, text, scriptKind);
31
+ const points = [];
32
+ const violations = [];
33
+ for (const point of raw) {
34
+ const inside = hidden.some((span) => point.start >= span.start && point.end <= span.end);
35
+ if (inside)
36
+ violations.push(point);
37
+ else
38
+ points.push(point);
39
+ }
40
+ return { points, violations };
41
+ }
42
+ function isTypePosition(node) {
43
+ return (ts.isTypeNode(node) ||
44
+ ts.isTypeAliasDeclaration(node) ||
45
+ ts.isInterfaceDeclaration(node) ||
46
+ ts.isTypeParameterDeclaration(node) ||
47
+ ts.isIndexSignatureDeclaration(node));
48
+ }
49
+ function pointAt(source, file, text, node) {
50
+ if (ts.isIfStatement(node) || ts.isWhileStatement(node) || ts.isDoStatement(node)) {
51
+ return negate(source, file, text, node.expression, "negate-condition");
52
+ }
53
+ if (ts.isConditionalExpression(node)) {
54
+ return negate(source, file, text, node.condition, "negate-ternary");
55
+ }
56
+ if (ts.isForStatement(node) && node.condition) {
57
+ return negate(source, file, text, node.condition, "negate-condition");
58
+ }
59
+ if (ts.isBinaryExpression(node)) {
60
+ const swap = SWAP[node.operatorToken.kind];
61
+ if (!swap)
62
+ return undefined;
63
+ const start = node.operatorToken.getStart(source);
64
+ const end = node.operatorToken.getEnd();
65
+ return make(source, file, text, start, end, swap.op, swap.replacement);
66
+ }
67
+ if (node.kind === ts.SyntaxKind.TrueKeyword || node.kind === ts.SyntaxKind.FalseKeyword) {
68
+ const start = node.getStart(source);
69
+ const end = node.getEnd();
70
+ const original = text.slice(start, end);
71
+ const replacement = original === "true" ? "false" : "true";
72
+ const op = original === "true" ? "true-to-false" : "false-to-true";
73
+ return make(source, file, text, start, end, op, replacement);
74
+ }
75
+ if (ts.isPrefixUnaryExpression(node) &&
76
+ node.operator === ts.SyntaxKind.ExclamationToken &&
77
+ !ts.isPrefixUnaryExpression(node.operand)) {
78
+ const start = node.getStart(source);
79
+ const end = node.operand.getStart(source);
80
+ return make(source, file, text, start, end, "drop-not", "");
81
+ }
82
+ return undefined;
83
+ }
84
+ function negate(source, file, text, expr, op) {
85
+ const start = expr.getStart(source);
86
+ const end = expr.getEnd();
87
+ return make(source, file, text, start, end, op, `!(${text.slice(start, end)})`);
88
+ }
89
+ function make(source, file, text, start, end, op, replacement) {
90
+ const { line } = source.getLineAndCharacterOfPosition(start);
91
+ return { file, start, end, line: line + 1, op, original: text.slice(start, end), replacement };
92
+ }
93
+ function hiddenSpans(source, text, scriptKind) {
94
+ const literals = [];
95
+ const visit = (node) => {
96
+ if (ts.isStringLiteral(node) ||
97
+ ts.isNoSubstitutionTemplateLiteral(node) ||
98
+ ts.isTemplateHead(node) ||
99
+ ts.isTemplateMiddle(node) ||
100
+ ts.isTemplateTail(node) ||
101
+ ts.isRegularExpressionLiteral(node) ||
102
+ ts.isJsxText(node)) {
103
+ literals.push({ start: node.getStart(source), end: node.getEnd() });
104
+ }
105
+ ts.forEachChild(node, visit);
106
+ };
107
+ visit(source);
108
+ const variant = scriptKind === ts.ScriptKind.TSX ? ts.LanguageVariant.JSX : ts.LanguageVariant.Standard;
109
+ const scanner = ts.createScanner(ts.ScriptTarget.Latest, false, variant, text);
110
+ const comments = [];
111
+ let token = scanner.scan();
112
+ while (token !== ts.SyntaxKind.EndOfFileToken) {
113
+ const leading = ts.getLeadingCommentRanges(text, scanner.getTokenStart()) ?? [];
114
+ const trailing = ts.getTrailingCommentRanges(text, scanner.getTokenEnd()) ?? [];
115
+ for (const comment of [...leading, ...trailing])
116
+ comments.push({ start: comment.pos, end: comment.end });
117
+ token = scanner.scan();
118
+ }
119
+ // A `//` inside a template is text, not a comment. Drop ranges that begin in a literal.
120
+ const real = comments.filter((comment) => !literals.some((span) => comment.start >= span.start && comment.start < span.end));
121
+ return [...literals, ...real];
122
+ }