probatio 0.0.2 → 0.0.4

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Aron Amos
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Probatio
2
2
 
3
- Probatio (Latin: a testing, a proof) is a free, open-source testing toolkit built for AI agents
3
+ Probatio (Latin: a testing, a proof) is a free testing toolkit under the MIT license, built for AI agents
4
4
  (Claude, Grok, ChatGPT, others), working alone or as swarms. Every claim it makes is checkable;
5
5
  it remembers what was fixed so agents with small context do not redo or undo work; and it finds
6
6
  the bugs a test suite would miss instead of asking anyone to read more tests.
@@ -32,10 +32,14 @@ export function writeJavaCoverage(pkg, testNames, dest) {
32
32
  const projectCp = buildClasspath(pkg, work);
33
33
  if ("error" in projectCp)
34
34
  return { ok: false, detail: projectCp.error };
35
+ // Surefire brings the platform launcher. A project's test classpath often has the Jupiter engine and not the launcher.
36
+ const launcher = platformLauncherJar(pkg, work, projectCp.cp);
37
+ if ("error" in launcher)
38
+ return { ok: false, detail: launcher.error };
35
39
  // 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);
40
+ const cp = [work, jars.core, jars.asm, jars.asmCommons, jars.asmTree, jars.junit, launcher.jar, ...classDirs, ...testClassDirs(pkg), projectCp.cp].filter(Boolean).join(path.delimiter);
37
41
  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);
42
+ const ran = run(javaTool("java"), [`-javaagent:${jars.agent}=output=none,excludes=ProbatioJacocoRun:org.junit.*:org.apiguardian.*:org.opentest4j.*`, "-cp", cp, "ProbatioJacocoRun", classDirs.join(path.delimiter), testsFile, dump, pkg], pkg, 900_000);
39
43
  if (ran.error && ran.error.code === "ETIMEDOUT") {
40
44
  return { ok: false, detail: "java coverage: timed out" };
41
45
  }
@@ -146,6 +150,31 @@ function resolveJars(pkg, work) {
146
150
  junit: paths["junit.jar"],
147
151
  };
148
152
  }
153
+ /** Launcher jar matching the project's junit-platform-engine, or none when this suite is not Jupiter. */
154
+ function platformLauncherJar(pkg, work, projectCp) {
155
+ const entries = projectCp.split(path.delimiter).filter(Boolean);
156
+ if (entries.some((item) => path.basename(item).startsWith("junit-platform-launcher")))
157
+ return { jar: "" };
158
+ const engine = entries.find((item) => path.basename(item).startsWith("junit-platform-engine-"));
159
+ if (!engine)
160
+ return { jar: "" };
161
+ const version = path.basename(engine).replace(/^junit-platform-engine-/, "").replace(/\.jar$/, "");
162
+ if (!version)
163
+ return { error: "java coverage: junit-platform-engine version missing" };
164
+ const dest = path.join(work, "launcher.jar");
165
+ if (!existsSync(dest)) {
166
+ const artifact = `org.junit.platform:junit-platform-launcher:${version}`;
167
+ const before = new Set(readdirSync(work));
168
+ const copied = run("mvn", ["-B", "-q", "org.apache.maven.plugins:maven-dependency-plugin:3.6.1:copy", `-Dartifact=${artifact}`, `-DoutputDirectory=${work}`], pkg);
169
+ if (copied.status !== 0)
170
+ return { error: clip(copied.stderr || copied.stdout || copied.error?.message || `mvn copy ${artifact} failed`) };
171
+ const created = readdirSync(work).find((item) => !before.has(item) && item.endsWith(".jar"));
172
+ if (!created)
173
+ return { error: `java coverage: missing ${artifact}` };
174
+ writeFileSync(dest, readFileSync(path.join(work, created)));
175
+ }
176
+ return { jar: dest };
177
+ }
149
178
  function buildClasspath(pkg, work) {
150
179
  const file = path.join(work, "classpath.txt");
151
180
  const built = run("mvn", ["-B", "-q", "dependency:build-classpath", "-DincludeScope=test", `-Dmdep.outputFile=${file}`], pkg);
@@ -179,8 +208,8 @@ function toolWorks(bin) {
179
208
  const text = `${probed.stdout ?? ""}${probed.stderr ?? ""}`;
180
209
  return probed.status === 0 && !/Unable to locate a Java Runtime/i.test(text);
181
210
  }
182
- function run(bin, args, cwd) {
183
- return spawnSync(bin, args, { cwd, encoding: "utf8", timeout: 180_000 });
211
+ function run(bin, args, cwd, timeout = 180_000) {
212
+ return spawnSync(bin, args, { cwd, encoding: "utf8", timeout });
184
213
  }
185
214
  function clip(text) {
186
215
  const line = text.trim().split("\n").find((item) => item.trim()) ?? "java coverage failed";
@@ -1,20 +1,52 @@
1
1
  import java.io.ByteArrayInputStream;
2
2
  import java.io.File;
3
+ import java.lang.reflect.Array;
4
+ import java.lang.reflect.InvocationHandler;
5
+ import java.lang.reflect.Proxy;
3
6
  import java.nio.file.Files;
7
+ import java.util.ArrayList;
8
+ import java.util.HashMap;
9
+ import java.util.LinkedHashMap;
4
10
  import java.util.List;
11
+ import java.util.Map;
5
12
  import org.jacoco.agent.rt.IAgent;
6
13
  import org.jacoco.agent.rt.RT;
7
14
  import org.jacoco.core.analysis.Analyzer;
8
15
  import org.jacoco.core.analysis.CoverageBuilder;
9
16
  import org.jacoco.core.analysis.IClassCoverage;
10
17
  import org.jacoco.core.analysis.ILine;
18
+ import org.jacoco.core.data.ExecutionData;
11
19
  import org.jacoco.core.data.ExecutionDataReader;
12
20
  import org.jacoco.core.data.ExecutionDataStore;
13
21
  import org.junit.runner.JUnitCore;
14
22
  import org.junit.runner.Request;
23
+ import org.junit.runner.Result;
15
24
 
16
- /** One JaCoCo dump per JUnit 4 method. Prints "HIT <file> <line>" lines. */
25
+ /**
26
+ * One JaCoCo dump per test method.
27
+ * JUnit 4 uses Request.method. JUnit 5 uses one platform launch for the whole list.
28
+ */
17
29
  public class ProbatioJacocoRun {
30
+ static Object jupiterLauncher;
31
+ static boolean jupiterMissing;
32
+ static String lastNote = "";
33
+ static IAgent agent;
34
+ static StringBuilder text;
35
+ static Map<String, Indexed> classFiles;
36
+ static File packageRoot;
37
+ static Map<String, String> jupiterNames = new HashMap<>();
38
+ static int executed;
39
+ static int recorded;
40
+
41
+ static final class Indexed {
42
+ final File file;
43
+ final File root;
44
+ Indexed(File file, File root) {
45
+ this.file = file;
46
+ this.root = root;
47
+ }
48
+ }
49
+
18
50
  public static void main(String[] args) throws Exception {
19
51
  if (args.length != 4) {
20
52
  System.err.println("usage: ProbatioJacocoRun classDirs testsFile outFile packageRoot");
@@ -23,55 +55,449 @@ public class ProbatioJacocoRun {
23
55
  String[] classDirs = args[0].split(File.pathSeparator);
24
56
  List<String> tests = Files.readAllLines(new File(args[1]).toPath());
25
57
  File out = new File(args[2]);
26
- File root = new File(args[3]);
27
- IAgent agent = RT.getAgent();
28
- StringBuilder text = new StringBuilder();
58
+ packageRoot = new File(args[3]);
59
+ classFiles = indexClasses(classDirs);
60
+ agent = RT.getAgent();
61
+ text = new StringBuilder();
62
+ List<String> jupiter = new ArrayList<>();
63
+ int requested = 0;
29
64
  for (String spec : tests) {
30
65
  int hash = spec.lastIndexOf('#');
31
66
  if (hash <= 0) continue;
67
+ requested++;
32
68
  String className = spec.substring(0, hash);
33
69
  String method = spec.substring(hash + 1);
34
70
  agent.getExecutionData(true);
35
- try {
36
- new JUnitCore().run(Request.method(Class.forName(className), method));
37
- } catch (Throwable ignored) {
71
+ if (runJUnit4(className, method)) {
72
+ executed++;
73
+ dump(className + "." + method);
74
+ continue;
75
+ }
76
+ jupiter.add(spec);
77
+ }
78
+ if (!jupiter.isEmpty()) runJupiter(jupiter);
79
+ Files.writeString(out.toPath(), text.toString());
80
+ if (requested > 0 && executed == 0) {
81
+ System.err.println("java coverage: no test executed " + lastNote);
82
+ System.exit(1);
83
+ }
84
+ if (executed > 0 && recorded == 0) {
85
+ System.err.println("java coverage: tests ran but JaCoCo recorded no classes");
86
+ System.exit(1);
87
+ }
88
+ // jsoup's integration tests leave Netty loops running. A normal return would
89
+ // wait on those threads until the parent clock kills the dump.
90
+ System.exit(0);
91
+ }
92
+
93
+ static Map<String, Indexed> indexClasses(String[] classDirs) {
94
+ Map<String, Indexed> found = new HashMap<>();
95
+ for (String dir : classDirs) {
96
+ if (dir.isEmpty()) continue;
97
+ File root = new File(dir);
98
+ if (root.isDirectory()) indexDir(root, root, found);
99
+ }
100
+ return found;
101
+ }
102
+
103
+ static void indexDir(File root, File dir, Map<String, Indexed> found) {
104
+ File[] kids = dir.listFiles();
105
+ if (kids == null) return;
106
+ for (File kid : kids) {
107
+ if (kid.isDirectory()) {
108
+ indexDir(root, kid, found);
38
109
  continue;
39
110
  }
40
- byte[] data = agent.getExecutionData(false);
41
- ExecutionDataStore store = new ExecutionDataStore();
42
- ExecutionDataReader reader = new ExecutionDataReader(new ByteArrayInputStream(data));
43
- reader.setExecutionDataVisitor(store);
44
- reader.setSessionInfoVisitor(info -> { });
45
- reader.read();
46
- String testName = className + "." + method;
47
- text.append("TEST ").append(testName).append('\n');
48
- for (String dir : classDirs) {
49
- if (dir.isEmpty()) continue;
50
- File classes = new File(dir);
51
- if (!classes.isDirectory()) continue;
52
- CoverageBuilder builder = new CoverageBuilder();
53
- Analyzer analyzer = new Analyzer(store, builder);
54
- analyzer.analyzeAll(classes);
55
- for (IClassCoverage cc : builder.getClasses()) {
56
- String source = cc.getSourceFileName();
57
- if (source == null) continue;
58
- File file = sourceFile(classes, cc.getPackageName(), source);
59
- if (file == null || !file.isFile()) continue;
60
- String rel = root.toPath().relativize(file.toPath()).toString().replace('\\', '/');
61
- int first = cc.getFirstLine();
62
- int last = cc.getLastLine();
63
- for (int line = first; line <= last; line++) {
64
- ILine cov = cc.getLine(line);
65
- if (cov.getInstructionCounter().getCoveredCount() <= 0) continue;
66
- text.append("HIT ").append(rel).append(' ').append(line).append(' ').append(testName).append('\n');
67
- }
111
+ String name = kid.getName();
112
+ if (!name.endsWith(".class")) continue;
113
+ String rel = root.toPath().relativize(kid.toPath()).toString().replace('\\', '/');
114
+ found.put(rel.substring(0, rel.length() - ".class".length()), new Indexed(kid, root));
115
+ }
116
+ }
117
+
118
+ static void dump(String testName) throws Exception {
119
+ byte[] data = agent.getExecutionData(false);
120
+ ExecutionDataStore store = new ExecutionDataStore();
121
+ ExecutionDataReader reader = new ExecutionDataReader(new ByteArrayInputStream(data));
122
+ reader.setExecutionDataVisitor(store);
123
+ reader.setSessionInfoVisitor(info -> { });
124
+ reader.read();
125
+ if (!store.getContents().isEmpty()) recorded++;
126
+ text.append("TEST ").append(testName).append('\n');
127
+ CoverageBuilder builder = new CoverageBuilder();
128
+ Analyzer analyzer = new Analyzer(store, builder);
129
+ for (ExecutionData exec : store.getContents()) {
130
+ if (!exec.hasHits()) continue;
131
+ Indexed indexed = classFiles.get(exec.getName());
132
+ if (indexed == null) continue;
133
+ analyzer.analyzeAll(indexed.file);
134
+ }
135
+ for (IClassCoverage cc : builder.getClasses()) {
136
+ String source = cc.getSourceFileName();
137
+ if (source == null) continue;
138
+ Indexed indexed = classFiles.get(cc.getName());
139
+ File file = sourceFile(indexed == null ? null : indexed.root, cc.getPackageName(), source);
140
+ if (file == null || !file.isFile()) continue;
141
+ String rel = packageRoot.toPath().relativize(file.toPath()).toString().replace('\\', '/');
142
+ int first = cc.getFirstLine();
143
+ int last = cc.getLastLine();
144
+ for (int line = first; line <= last; line++) {
145
+ ILine cov = cc.getLine(line);
146
+ if (cov.getInstructionCounter().getCoveredCount() <= 0) continue;
147
+ text.append("HIT ").append(rel).append(' ').append(line).append(' ').append(testName).append('\n');
148
+ }
149
+ }
150
+ }
151
+
152
+ /** JUnit 4 only. A Jupiter method has no org.junit.Test, and Request.method must not count as a run. */
153
+ static boolean runJUnit4(String className, String method) {
154
+ // Read the class file. Class.forName would initialize every Jupiter test before the batch.
155
+ if (!classFileMentionsJUnit4(className)) return false;
156
+ try {
157
+ Class<?> type = Class.forName(className);
158
+ if (!hasJUnit4Test(type, method)) return false;
159
+ Result result = new JUnitCore().run(Request.method(type, method));
160
+ if (result.getRunCount() <= 0) return false;
161
+ for (org.junit.runner.notification.Failure failure : result.getFailures()) {
162
+ String message = String.valueOf(failure.getMessage());
163
+ if (message.contains("No tests found") || message.contains("No runnable methods") || message.contains("initializationError")) {
164
+ return false;
68
165
  }
69
166
  }
167
+ return true;
168
+ } catch (Throwable ignored) {
169
+ return false;
70
170
  }
71
- Files.writeString(out.toPath(), text.toString());
171
+ }
172
+
173
+ static boolean classFileMentionsJUnit4(String className) {
174
+ String resource = className.replace('.', '/') + ".class";
175
+ try (java.io.InputStream in = ClassLoader.getSystemResourceAsStream(resource)) {
176
+ if (in == null) return false;
177
+ byte[] bytes = in.readAllBytes();
178
+ String latin = new String(bytes, java.nio.charset.StandardCharsets.ISO_8859_1);
179
+ return latin.contains("Lorg/junit/Test;");
180
+ } catch (java.io.IOException error) {
181
+ return false;
182
+ }
183
+ }
184
+
185
+ static boolean hasJUnit4Test(Class<?> type, String method) {
186
+ String simple = jupiterMethod(method);
187
+ for (java.lang.reflect.Method candidate : type.getDeclaredMethods()) {
188
+ if (!candidate.getName().equals(simple)) continue;
189
+ for (java.lang.annotation.Annotation ann : candidate.getDeclaredAnnotations()) {
190
+ if (ann.annotationType().getName().equals("org.junit.Test")) return true;
191
+ }
192
+ }
193
+ return false;
194
+ }
195
+
196
+ /** Simple method name. A unique id may append "()" or "(java.lang.String)". */
197
+ static String jupiterMethod(String method) {
198
+ int end = 0;
199
+ while (end < method.length()) {
200
+ char c = method.charAt(end);
201
+ if (Character.isLetterOrDigit(c) || c == '_' || c == '$') end++;
202
+ else break;
203
+ }
204
+ return end == 0 ? method : method.substring(0, end);
205
+ }
206
+
207
+ /**
208
+ * Surefire names a parameterized invocation "method(String)[1]".
209
+ * selectMethod(class, name) leaves the parameter list empty, and JUnit 5.14
210
+ * then runs nothing if any one selector fails. Keep the parameter types, and
211
+ * leave out a name that does not resolve.
212
+ */
213
+ static final class JupiterSpec {
214
+ final String className;
215
+ final String simple;
216
+ final Class<?>[] types;
217
+ JupiterSpec(String className, String simple, Class<?>[] types) {
218
+ this.className = className;
219
+ this.simple = simple;
220
+ this.types = types;
221
+ }
222
+ }
223
+
224
+ static final Map<String, Class<?>> loadedTests = new HashMap<>();
225
+
226
+ static void runJupiter(List<String> specs) {
227
+ if (jupiterMissing) {
228
+ lastNote = "jupiter launcher missing";
229
+ return;
230
+ }
231
+ try {
232
+ Map<String, JupiterSpec> unique = new LinkedHashMap<>();
233
+ int skipped = 0;
234
+ for (String spec : specs) {
235
+ JupiterSpec parsed = parseJupiterSpec(spec);
236
+ if (parsed == null) {
237
+ skipped++;
238
+ continue;
239
+ }
240
+ String key = parsed.className + "#" + parsed.simple + "#" + typeKey(parsed.types);
241
+ unique.putIfAbsent(key, parsed);
242
+ jupiterNames.put(parsed.className + "#" + parsed.simple, parsed.className + "." + parsed.simple);
243
+ }
244
+ if (unique.isEmpty()) {
245
+ lastNote = "jupiter no resolvable methods skipped=" + skipped;
246
+ return;
247
+ }
248
+ if (jupiterLauncher == null) {
249
+ Class<?> factory = Class.forName("org.junit.platform.launcher.core.LauncherFactory");
250
+ jupiterLauncher = factory.getMethod("create").invoke(null);
251
+ }
252
+ Class<?> selectorType = Class.forName("org.junit.platform.engine.DiscoverySelector");
253
+ Class<?> selectorsType = Class.forName("org.junit.platform.engine.discovery.DiscoverySelectors");
254
+ java.lang.reflect.Method select = selectMethodWithTypes(selectorsType);
255
+ Object selectors = Array.newInstance(selectorType, unique.size());
256
+ int index = 0;
257
+ for (JupiterSpec spec : unique.values()) {
258
+ Class<?> type = loadTestClass(spec.className);
259
+ Object selector = select.invoke(null, new Object[] { type, spec.simple, spec.types });
260
+ Array.set(selectors, index++, selector);
261
+ }
262
+ Class<?> builderType = Class.forName("org.junit.platform.launcher.core.LauncherDiscoveryRequestBuilder");
263
+ Object builder = builderType.getMethod("request").invoke(null);
264
+ builderType.getMethod("configurationParameter", String.class, String.class)
265
+ .invoke(builder, "junit.jupiter.execution.parallel.enabled", "false");
266
+ builderType.getMethod("selectors", Array.newInstance(selectorType, 0).getClass()).invoke(builder, selectors);
267
+ Object request = builderType.getMethod("build").invoke(builder);
268
+ Class<?> listenerType = Class.forName("org.junit.platform.launcher.TestExecutionListener");
269
+ Object listener = Proxy.newProxyInstance(listenerType.getClassLoader(), new Class<?>[] { listenerType }, new JupiterDump());
270
+ Object listeners = Array.newInstance(listenerType, 1);
271
+ Array.set(listeners, 0, listener);
272
+ Class<?> requestType = Class.forName("org.junit.platform.launcher.LauncherDiscoveryRequest");
273
+ Class<?> launcherType = Class.forName("org.junit.platform.launcher.Launcher");
274
+ launcherType.getMethod("execute", requestType, listenerType.arrayType()).invoke(jupiterLauncher, request, listeners);
275
+ if (executed == 0) lastNote = "jupiter started=0 resolved=" + unique.size() + " skipped=" + skipped;
276
+ } catch (ClassNotFoundException missing) {
277
+ String missingName = String.valueOf(missing.getMessage());
278
+ if (missingName.contains("org.junit.platform")) jupiterMissing = true;
279
+ lastNote = "jupiter missing " + missingName;
280
+ } catch (Throwable error) {
281
+ Throwable cause = error.getCause() == null ? error : error.getCause();
282
+ lastNote = "jupiter " + cause.getClass().getSimpleName() + " " + cause.getMessage();
283
+ }
284
+ }
285
+
286
+ static JupiterSpec parseJupiterSpec(String spec) {
287
+ int hash = spec.lastIndexOf('#');
288
+ if (hash <= 0) return null;
289
+ String className = spec.substring(0, hash).trim();
290
+ String method = spec.substring(hash + 1).trim();
291
+ while (method.endsWith("]")) {
292
+ int open = method.lastIndexOf('[');
293
+ if (open <= 0) return null;
294
+ method = method.substring(0, open).trim();
295
+ }
296
+ String simple;
297
+ String params;
298
+ int paren = method.indexOf('(');
299
+ if (paren < 0) {
300
+ simple = method;
301
+ params = null;
302
+ } else if (method.endsWith(")")) {
303
+ simple = method.substring(0, paren).trim();
304
+ params = method.substring(paren + 1, method.length() - 1).trim();
305
+ } else {
306
+ return null;
307
+ }
308
+ if (!isJavaIdentifier(simple) || className.isEmpty()) return null;
309
+ Class<?>[] types = parameterTypes(className, simple, params);
310
+ if (types == null) return null;
311
+ return new JupiterSpec(className, simple, types);
312
+ }
313
+
314
+ static boolean isJavaIdentifier(String name) {
315
+ if (name.isEmpty()) return false;
316
+ if (!Character.isJavaIdentifierStart(name.charAt(0))) return false;
317
+ for (int i = 1; i < name.length(); i++) {
318
+ if (!Character.isJavaIdentifierPart(name.charAt(i))) return false;
319
+ }
320
+ return true;
321
+ }
322
+
323
+ /** params is null when the report gave no signature. An empty string is "()". */
324
+ static Class<?>[] parameterTypes(String className, String simple, String params) {
325
+ Class<?> type = loadTestClass(className);
326
+ if (type == null) return null;
327
+ List<java.lang.reflect.Method> named = new ArrayList<>();
328
+ for (Class<?> cursor = type; cursor != null && cursor != Object.class; cursor = cursor.getSuperclass()) {
329
+ java.lang.reflect.Method[] methods;
330
+ try {
331
+ methods = cursor.getDeclaredMethods();
332
+ } catch (Throwable error) {
333
+ continue;
334
+ }
335
+ for (java.lang.reflect.Method candidate : methods) {
336
+ if (candidate.isBridge() || candidate.isSynthetic()) continue;
337
+ if (candidate.getName().equals(simple)) named.add(candidate);
338
+ }
339
+ }
340
+ if (named.isEmpty()) return null;
341
+ if (params == null) {
342
+ java.lang.reflect.Method noArg = null;
343
+ for (java.lang.reflect.Method candidate : named) {
344
+ if (candidate.getParameterCount() == 0) noArg = candidate;
345
+ }
346
+ if (noArg != null) return noArg.getParameterTypes();
347
+ return named.size() == 1 ? named.get(0).getParameterTypes() : null;
348
+ }
349
+ String[] wanted = splitParams(params);
350
+ java.lang.reflect.Method match = null;
351
+ for (java.lang.reflect.Method candidate : named) {
352
+ if (!paramsMatch(candidate.getParameterTypes(), wanted)) continue;
353
+ if (match != null) return null;
354
+ match = candidate;
355
+ }
356
+ return match == null ? null : match.getParameterTypes();
357
+ }
358
+
359
+ static Class<?> loadTestClass(String className) {
360
+ if (loadedTests.containsKey(className)) return loadedTests.get(className);
361
+ try {
362
+ Class<?> type = Class.forName(className);
363
+ loadedTests.put(className, type);
364
+ return type;
365
+ } catch (Throwable error) {
366
+ loadedTests.put(className, null);
367
+ return null;
368
+ }
369
+ }
370
+
371
+ static String[] splitParams(String params) {
372
+ if (params.isEmpty()) return new String[0];
373
+ List<String> parts = new ArrayList<>();
374
+ int depth = 0;
375
+ int start = 0;
376
+ for (int i = 0; i < params.length(); i++) {
377
+ char c = params.charAt(i);
378
+ if (c == '<' || c == '(') depth++;
379
+ else if (c == '>' || c == ')') depth = Math.max(0, depth - 1);
380
+ else if (c == ',' && depth == 0) {
381
+ parts.add(params.substring(start, i).trim());
382
+ start = i + 1;
383
+ }
384
+ }
385
+ String tail = params.substring(start).trim();
386
+ if (!tail.isEmpty()) parts.add(tail);
387
+ return parts.toArray(new String[0]);
388
+ }
389
+
390
+ static boolean paramsMatch(Class<?>[] actual, String[] wanted) {
391
+ if (actual.length != wanted.length) return false;
392
+ for (int i = 0; i < actual.length; i++) {
393
+ if (!typeMatches(actual[i], wanted[i])) return false;
394
+ }
395
+ return true;
396
+ }
397
+
398
+ static boolean typeMatches(Class<?> actual, String wanted) {
399
+ String name = wanted.trim();
400
+ if (name.endsWith("...")) name = name.substring(0, name.length() - 3).trim() + "[]";
401
+ int dims = 0;
402
+ while (name.endsWith("[]")) {
403
+ dims++;
404
+ name = name.substring(0, name.length() - 2).trim();
405
+ }
406
+ Class<?> cursor = actual;
407
+ for (int i = 0; i < dims; i++) {
408
+ if (cursor == null || !cursor.isArray()) return false;
409
+ cursor = cursor.getComponentType();
410
+ }
411
+ if (cursor == null || cursor.isArray() || name.isEmpty()) return false;
412
+ if (name.equals(cursor.getSimpleName()) || name.equals(cursor.getName())) return true;
413
+ String binary = cursor.getName().replace('$', '.');
414
+ if (name.equals(binary) || binary.endsWith("." + name)) return true;
415
+ String canon = cursor.getCanonicalName();
416
+ return canon != null && (name.equals(canon) || canon.endsWith("." + name));
417
+ }
418
+
419
+ static String typeKey(Class<?>[] types) {
420
+ StringBuilder key = new StringBuilder();
421
+ for (Class<?> type : types) {
422
+ if (key.length() > 0) key.append(',');
423
+ key.append(type.getName());
424
+ }
425
+ return key.toString();
426
+ }
427
+
428
+ static java.lang.reflect.Method selectMethodWithTypes(Class<?> selectorsType) throws NoSuchMethodException {
429
+ for (java.lang.reflect.Method candidate : selectorsType.getMethods()) {
430
+ if (!candidate.getName().equals("selectMethod")) continue;
431
+ Class<?>[] params = candidate.getParameterTypes();
432
+ if (params.length == 3 && params[0] == Class.class && params[1] == String.class && params[2].isArray() && params[2].getComponentType() == Class.class) {
433
+ return candidate;
434
+ }
435
+ }
436
+ throw new NoSuchMethodException("selectMethod(Class, String, Class[])");
437
+ }
438
+
439
+ static final class JupiterDump implements InvocationHandler {
440
+ public Object invoke(Object proxy, java.lang.reflect.Method method, Object[] args) {
441
+ if (method.getDeclaringClass() == Object.class) {
442
+ if (method.getName().equals("toString")) return "ProbatioJupiterDump";
443
+ if (method.getName().equals("hashCode")) return System.identityHashCode(proxy);
444
+ if (method.getName().equals("equals")) return args != null && args.length > 0 && proxy == args[0];
445
+ }
446
+ if (args == null || args.length == 0) return null;
447
+ try {
448
+ if (method.getName().equals("executionStarted")) noteStart(args[0]);
449
+ else if (method.getName().equals("executionFinished")) noteFinish(args[0]);
450
+ } catch (Throwable error) {
451
+ lastNote = "jupiter dump " + error.getClass().getSimpleName();
452
+ }
453
+ return null;
454
+ }
455
+ }
456
+
457
+ static String activeTest;
458
+
459
+ static void noteStart(Object identifier) throws Exception {
460
+ String name = testName(identifier);
461
+ if (name == null) return;
462
+ activeTest = name;
463
+ agent.getExecutionData(true);
464
+ }
465
+
466
+ static void noteFinish(Object identifier) throws Exception {
467
+ String name = testName(identifier);
468
+ if (name == null || !name.equals(activeTest)) return;
469
+ dump(name);
470
+ executed++;
471
+ activeTest = null;
472
+ }
473
+
474
+ static String testName(Object identifier) throws Exception {
475
+ if (!Boolean.TRUE.equals(identifier.getClass().getMethod("isTest").invoke(identifier))) return null;
476
+ Object uid = identifier.getClass().getMethod("getUniqueId").invoke(identifier);
477
+ String text = String.valueOf(uid);
478
+ String className = lastSegment(text, "nested-class");
479
+ if (className == null) className = lastSegment(text, "class");
480
+ String method = lastSegment(text, "method");
481
+ if (method == null) method = lastSegment(text, "test-template");
482
+ if (className == null || method == null) return null;
483
+ if (className.indexOf('.') < 0) {
484
+ String outer = lastSegment(text, "class");
485
+ if (outer != null && outer.indexOf('.') >= 0) className = outer + "$" + className;
486
+ }
487
+ return jupiterNames.get(className + "#" + jupiterMethod(method));
488
+ }
489
+
490
+ static String lastSegment(String uid, String kind) {
491
+ String token = "[" + kind + ":";
492
+ int start = uid.lastIndexOf(token);
493
+ if (start < 0) return null;
494
+ int end = uid.indexOf(']', start);
495
+ if (end < 0) return null;
496
+ return uid.substring(start + token.length(), end);
72
497
  }
73
498
 
74
499
  static File sourceFile(File classes, String pkg, String source) {
500
+ if (classes == null) return null;
75
501
  File target = classes.getParentFile();
76
502
  File module = target == null ? null : target.getParentFile();
77
503
  if (module == null) return null;
@@ -357,8 +357,10 @@ function escapeRegExp(value) {
357
357
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
358
358
  }
359
359
  function runShell(pkg, command, timeoutMs, env) {
360
+ // A command can be pytest. Baseline writes a .pyc, and a same-size patch in that same second leaves it valid.
361
+ clearBytecode(pkg);
360
362
  const child = /\b(java|javac|mvn)\b/.test(command) ? javaEnv(env) : env;
361
- return spawnCollected("sh", ["-c", command], pkg, child, timeoutMs);
363
+ return spawnCollected("sh", ["-c", command], pkg, { ...child, PYTHONDONTWRITEBYTECODE: "1" }, timeoutMs);
362
364
  }
363
365
  async function runNode(pkg, files, pattern, timeoutMs, env, hooks) {
364
366
  const tsx = path.join(pkg, "node_modules", ".bin", "tsx");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "probatio",
3
- "version": "0.0.2",
3
+ "version": "0.0.4",
4
4
  "description": "Mutation runner for AI agents. Scores a test suite by the bugs it catches.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -14,6 +14,7 @@
14
14
  "url": "git+https://github.com/IronAdamant/Probatio.git"
15
15
  },
16
16
  "homepage": "https://github.com/IronAdamant/Probatio#readme",
17
+ "license": "MIT",
17
18
  "bugs": {
18
19
  "url": "https://github.com/IronAdamant/Probatio/issues"
19
20
  },