@gabjauf/vitest-dse 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,209 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Babel plugin for DSE instrumentation.
5
+ *
6
+ * Rewrites every JS operation that can involve symbolic values into a call
7
+ * on the global __DSE__ dispatcher:
8
+ *
9
+ * a + b → __DSE__.B(iid, '+', a, b)
10
+ * !a → __DSE__.U(iid, '!', a)
11
+ * obj[key] → __DSE__.G(iid, obj, key)
12
+ * obj[key] = v → __DSE__.P(iid, obj, key, v)
13
+ * if (cond) → if (__DSE__.C(iid, cond))
14
+ * f(a, b) → __DSE__.I(iid, f, undefined, [a, b])
15
+ * obj.m(a) → __DSE__.I(iid, obj.m, obj, [a])
16
+ *
17
+ * Notes:
18
+ * - path.skip() is intentionally NOT called after replacements so that
19
+ * nested expressions (including arrow function bodies in arguments) are
20
+ * also instrumented.
21
+ * - Re-entry into __DSE__.*() calls is blocked at the top of each handler.
22
+ *
23
+ * Options:
24
+ * nextIid(line, col) — function returning a unique integer per instrumented site;
25
+ * receives the 1-based line and 0-based column from the AST.
26
+ */
27
+ module.exports = function dseBabelPlugin({ types: t }) {
28
+ function dseCall(method, args) {
29
+ return t.callExpression(
30
+ t.memberExpression(t.identifier("__DSE__"), t.identifier(method)),
31
+ args
32
+ );
33
+ }
34
+
35
+ function iidLit(iid) {
36
+ return t.numericLiteral(iid);
37
+ }
38
+
39
+ /** True if the node is a __DSE__.*(…) call — skip re-instrumentation. */
40
+ function isDseCall(node) {
41
+ return (
42
+ t.isCallExpression(node) &&
43
+ t.isMemberExpression(node.callee) &&
44
+ t.isIdentifier(node.callee.object, { name: "__DSE__" })
45
+ );
46
+ }
47
+
48
+ /** Extract [line, col] from a Babel AST node's start location. */
49
+ function nodeLoc(node) {
50
+ const start = node.loc?.start;
51
+ return [start?.line ?? null, start?.column ?? null];
52
+ }
53
+
54
+ return {
55
+ visitor: {
56
+ // ── Binary operators: a OP b → __DSE__.B(iid, 'OP', a, b) ────────────
57
+ BinaryExpression(path, state) {
58
+ if (isDseCall(path.node)) return;
59
+ const [l, c] = nodeLoc(path.node);
60
+ const iid = state.opts.nextIid(l, c);
61
+ path.replaceWith(
62
+ dseCall("B", [
63
+ iidLit(iid),
64
+ t.stringLiteral(path.node.operator),
65
+ path.node.left,
66
+ path.node.right,
67
+ ])
68
+ );
69
+ // Do NOT call path.skip() — Babel must continue visiting operands
70
+ // so that nested binary expressions and member accesses are also rewritten.
71
+ },
72
+
73
+ // ── Unary operators: OP a → __DSE__.U(iid, 'OP', a) ─────────────────
74
+ UnaryExpression(path, state) {
75
+ if (path.node.operator === "void" || path.node.operator === "delete") return;
76
+ if (isDseCall(path.node)) return;
77
+ const [l, c] = nodeLoc(path.node);
78
+ const iid = state.opts.nextIid(l, c);
79
+ path.replaceWith(
80
+ dseCall("U", [
81
+ iidLit(iid),
82
+ t.stringLiteral(path.node.operator),
83
+ path.node.argument,
84
+ ])
85
+ );
86
+ },
87
+
88
+ // ── Property reads: obj.f / obj[k] → __DSE__.G(iid, obj, key) ────────
89
+ MemberExpression(path, state) {
90
+ // Skip: callee of a call we're about to handle
91
+ if (
92
+ path.parentPath.isCallExpression() &&
93
+ path.parentPath.node.callee === path.node
94
+ ) return;
95
+ // Skip: LHS of assignment
96
+ if (
97
+ path.parentPath.isAssignmentExpression() &&
98
+ path.parentPath.node.left === path.node
99
+ ) return;
100
+ // Skip: already a __DSE__ member
101
+ if (t.isIdentifier(path.node.object, { name: "__DSE__" })) return;
102
+
103
+ const [l, c] = nodeLoc(path.node);
104
+ const iid = state.opts.nextIid(l, c);
105
+ const key = path.node.computed
106
+ ? path.node.property
107
+ : t.stringLiteral(path.node.property.name);
108
+
109
+ path.replaceWith(dseCall("G", [iidLit(iid), path.node.object, key]));
110
+ },
111
+
112
+ // ── Property writes: obj.f = v → __DSE__.P(iid, obj, key, v) ─────────
113
+ AssignmentExpression(path, state) {
114
+ if (!t.isMemberExpression(path.node.left)) return;
115
+ if (path.node.operator !== "=") return;
116
+ if (isDseCall(path.node)) return;
117
+
118
+ const [l, c] = nodeLoc(path.node);
119
+ const iid = state.opts.nextIid(l, c);
120
+ const member = path.node.left;
121
+ const key = member.computed
122
+ ? member.property
123
+ : t.stringLiteral(member.property.name);
124
+
125
+ path.replaceWith(
126
+ dseCall("P", [iidLit(iid), member.object, key, path.node.right])
127
+ );
128
+ },
129
+
130
+ // ── Branch conditions ─────────────────────────────────────────────────
131
+ IfStatement(path, state) {
132
+ const [l, c] = nodeLoc(path.node.test);
133
+ path.node.test = dseCall("C", [iidLit(state.opts.nextIid(l, c)), path.node.test]);
134
+ },
135
+ WhileStatement(path, state) {
136
+ const [l, c] = nodeLoc(path.node.test);
137
+ path.node.test = dseCall("C", [iidLit(state.opts.nextIid(l, c)), path.node.test]);
138
+ },
139
+ DoWhileStatement(path, state) {
140
+ const [l, c] = nodeLoc(path.node.test);
141
+ path.node.test = dseCall("C", [iidLit(state.opts.nextIid(l, c)), path.node.test]);
142
+ },
143
+ ForStatement(path, state) {
144
+ if (path.node.test) {
145
+ const [l, c] = nodeLoc(path.node.test);
146
+ path.node.test = dseCall("C", [iidLit(state.opts.nextIid(l, c)), path.node.test]);
147
+ }
148
+ },
149
+ ConditionalExpression(path, state) {
150
+ const [l, c] = nodeLoc(path.node.test);
151
+ path.node.test = dseCall("C", [iidLit(state.opts.nextIid(l, c)), path.node.test]);
152
+ },
153
+
154
+ // Logical expressions short-circuit — treat like binary operators
155
+ LogicalExpression(path, state) {
156
+ if (isDseCall(path.node)) return;
157
+ const [l, c] = nodeLoc(path.node);
158
+ const iid = state.opts.nextIid(l, c);
159
+ path.replaceWith(
160
+ dseCall("B", [
161
+ iidLit(iid),
162
+ t.stringLiteral(path.node.operator),
163
+ path.node.left,
164
+ path.node.right,
165
+ ])
166
+ );
167
+ },
168
+
169
+ // ── Function calls: f(a,b) → __DSE__.I(iid, f, undefined, [a,b]) ────
170
+ CallExpression(path, state) {
171
+ // Skip already-instrumented __DSE__.*() calls to prevent infinite loops
172
+ if (isDseCall(path.node)) return;
173
+ // Skip require() — must remain a static call so Vite can scan dependencies
174
+ if (t.isIdentifier(path.node.callee, { name: "require" })) return;
175
+
176
+ const [l, c] = nodeLoc(path.node);
177
+ const iid = state.opts.nextIid(l, c);
178
+ const callee = path.node.callee;
179
+ let fn, thisArg;
180
+
181
+ if (t.isMemberExpression(callee) &&
182
+ !t.isIdentifier(callee.object, { name: "__DSE__" })) {
183
+ const keyNode = callee.computed
184
+ ? callee.property
185
+ : t.stringLiteral(callee.property.name);
186
+ // G iid uses the callee's location (the method reference, not the call site)
187
+ const [gl, gc] = nodeLoc(callee);
188
+ const objIid = state.opts.nextIid(gl, gc);
189
+ fn = dseCall("G", [iidLit(objIid), callee.object, keyNode]);
190
+ thisArg = callee.object;
191
+ } else {
192
+ fn = callee;
193
+ thisArg = t.identifier("undefined");
194
+ }
195
+
196
+ path.replaceWith(
197
+ dseCall("I", [
198
+ iidLit(iid),
199
+ fn,
200
+ thisArg,
201
+ t.arrayExpression(path.node.arguments),
202
+ ])
203
+ );
204
+ // Do NOT call path.skip() — Babel must continue visiting the arguments
205
+ // so arrow functions passed as callbacks are fully instrumented.
206
+ },
207
+ },
208
+ };
209
+ };
package/coverage.js ADDED
@@ -0,0 +1,56 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Babel-friendly coverage tracker.
5
+ *
6
+ * Uses iids generated by the Babel plugin (simple integers) rather than
7
+ * Jalangi2's sid+iid pairs. Maps each iid to a { file, line, col } entry
8
+ * that was recorded at transform time.
9
+ */
10
+ class BabelCoverage {
11
+ /**
12
+ * @param {Map<number, {file: string, line: number, col: number}>} iidMap
13
+ */
14
+ constructor(iidMap) {
15
+ this._iidMap = iidMap;
16
+ this._touched = new Set();
17
+ this._conditionals = new Map(); // iid → { true: bool, false: bool }
18
+ }
19
+
20
+ touch(iid) {
21
+ this._touched.add(iid);
22
+ }
23
+
24
+ touch_cnd(iid, result) {
25
+ this._touched.add(iid);
26
+ if (!this._conditionals.has(iid)) {
27
+ this._conditionals.set(iid, { true: false, false: false });
28
+ }
29
+ this._conditionals.get(iid)[String(result)] = true;
30
+ }
31
+
32
+ reset() {
33
+ this._touched.clear();
34
+ this._conditionals.clear();
35
+ }
36
+
37
+ end() {
38
+ const touched = [];
39
+ for (const iid of this._touched) {
40
+ const loc = this._iidMap?.get(iid);
41
+ if (loc) touched.push(loc);
42
+ }
43
+ return { touched };
44
+ }
45
+
46
+ /** Return the last touched iid (used by ExpoSE's search strategy). */
47
+ last() {
48
+ let last = -1;
49
+ for (const iid of this._touched) {
50
+ if (iid > last) last = iid;
51
+ }
52
+ return last;
53
+ }
54
+ }
55
+
56
+ module.exports = { BabelCoverage };
@@ -0,0 +1,45 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Single source of truth for iid assignment across the two instrumentation
5
+ * paths (Vite transform hook in plugin.js and Module._extensions hook in
6
+ * setup.js).
7
+ *
8
+ * Backed by process.* so all module instances share the same counter and map,
9
+ * even inside Vitest's vm-isolated contexts where `global` is per-context.
10
+ *
11
+ * iid (integer) → { file: string, line: number|null, col: number|null }
12
+ */
13
+
14
+ if (process.__DSE_IID_COUNTER__ === undefined) process.__DSE_IID_COUNTER__ = 0;
15
+ if (!process.__DSE_IID_MAP__) process.__DSE_IID_MAP__ = new Map();
16
+
17
+ /**
18
+ * Allocate the next iid and record its source location.
19
+ * @param {string} file Absolute path of the file being instrumented.
20
+ * @param {number|null} line 1-based line number from the AST node.
21
+ * @param {number|null} col 0-based column from the AST node.
22
+ * @returns {number} The allocated iid.
23
+ */
24
+ function nextIid(file, line, col) {
25
+ const iid = process.__DSE_IID_COUNTER__++;
26
+ process.__DSE_IID_MAP__.set(iid, {
27
+ file,
28
+ line: line ?? null,
29
+ col: col ?? null,
30
+ });
31
+ return iid;
32
+ }
33
+
34
+ /** Return the live iid→location map (mutated by nextIid). */
35
+ function getIidMap() {
36
+ return process.__DSE_IID_MAP__;
37
+ }
38
+
39
+ /** Reset counter and map (useful in tests). */
40
+ function resetRegistry() {
41
+ process.__DSE_IID_COUNTER__ = 0;
42
+ process.__DSE_IID_MAP__ = new Map();
43
+ }
44
+
45
+ module.exports = { nextIid, getIidMap, resetRegistry };
package/index.js ADDED
@@ -0,0 +1,323 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * vitest-dse — DSE-powered property testing for Vitest.
5
+ *
6
+ * Two APIs:
7
+ *
8
+ * 1. Low-level: dse(config, opts?) → Violation[]
9
+ * Run symbolic execution and get violations back as a plain array.
10
+ * Use with Vitest's native test() + expect():
11
+ *
12
+ * test("abs never returns negative", () => {
13
+ * const v = dse({
14
+ * fn: (x) => x >= 0 ? x : x,
15
+ * symbols: { x: { default: -1 } },
16
+ * args: ["x"],
17
+ * assert: (r) => r >= 0,
18
+ * });
19
+ * expect(v).toHaveLength(1);
20
+ * expect(v[0].input.x).toBeLessThan(0);
21
+ * });
22
+ *
23
+ * 2. High-level: symbolicTest(name, config, opts?) — wraps test() for you.
24
+ * Kept for backwards compatibility.
25
+ */
26
+
27
+ const path = require("path");
28
+ const { setCurrentState } = require("./runtime");
29
+ const { BabelCoverage } = require("./coverage");
30
+ const { getIidMap } = require("./iid-registry");
31
+
32
+ // Vitest injects `test` as a global in the test environment
33
+ /* global test */
34
+
35
+ const analyserBin = process.env.DSE_ANALYSER_BIN ||
36
+ path.join(path.dirname(require.resolve("@gabjauf/expose-analyser/package.json")), "bin");
37
+ const SymbolicStateModule = require(path.join(analyserBin, "SymbolicState"));
38
+ const SymbolicState = SymbolicStateModule.default || SymbolicStateModule;
39
+
40
+ // ── Helpers ────────────────────────────────────────────────────────────────────
41
+
42
+ /** Build the initial concrete input from symbols' default values. */
43
+ function initialInput(symbols) {
44
+ const input = { _bound: 0 };
45
+ for (const [name, cfg] of Object.entries(symbols)) {
46
+ input[name] = cfg.default;
47
+ }
48
+ return input;
49
+ }
50
+
51
+ /** Extract concrete values for display in error messages. */
52
+ function concretizeInput(input, state) {
53
+ const out = {};
54
+ for (const [k, v] of Object.entries(input)) {
55
+ if (k === "_bound") continue;
56
+ out[k] = state ? state.getConcrete(v) : v;
57
+ }
58
+ return out;
59
+ }
60
+
61
+ /** Recursively replace ConcolicValues with their concrete counterparts.
62
+ * ConcolicValues are detected by their { concrete, symbolic } shape. */
63
+ function deepConcretize(value) {
64
+ if (value === null || value === undefined) return value;
65
+ if (typeof value === "object" && "concrete" in value && "symbolic" in value) {
66
+ return deepConcretize(value.concrete);
67
+ }
68
+ if (Array.isArray(value)) return value.map(deepConcretize);
69
+ if (typeof value === "object") {
70
+ const out = {};
71
+ for (const [k, v] of Object.entries(value)) out[k] = deepConcretize(v);
72
+ return out;
73
+ }
74
+ return value;
75
+ }
76
+
77
+ function formatViolations(violations) {
78
+ const lines = [`DSE found ${violations.length} violation(s):`];
79
+ for (const { input, result, error } of violations) {
80
+ if (error) {
81
+ lines.push(` input=${JSON.stringify(input)} threw: ${error}`);
82
+ } else {
83
+ lines.push(` input=${JSON.stringify(input)} result=${JSON.stringify(result)}`);
84
+ }
85
+ }
86
+ return lines.join("\n");
87
+ }
88
+
89
+ // ── Core DSE loop ──────────────────────────────────────────────────────────────
90
+
91
+ /**
92
+ * Run DSE on a function and return all violations found.
93
+ *
94
+ * @param {object} config
95
+ * fn — function under test (instrumented by the Babel plugin)
96
+ * symbols — { name: { default, min?, max? } }
97
+ * args — ordered list of symbol names to pass as fn arguments
98
+ * assert — (result) => boolean (must be in an instrumented file)
99
+ * @param {object} [opts]
100
+ * maxPaths — path budget (default 200)
101
+ * @returns {Array<{ input, result } | { input, error }>}
102
+ */
103
+ function dse(config, opts = {}) {
104
+ const maxPaths = opts.maxPaths ?? 200;
105
+ const { fn, symbols, assert: assertFn } = config;
106
+ const args = config.args ?? Object.keys(symbols);
107
+
108
+ const violations = [];
109
+ const violationKeys = new Set(); // deduplicate by concrete input
110
+ const queue = [initialInput(symbols)];
111
+ const visited = new Set();
112
+
113
+ while (queue.length > 0 && visited.size < maxPaths) {
114
+ const input = queue.shift();
115
+
116
+ const bound = input._bound;
117
+ if (!Number.isInteger(bound) || bound < 0) {
118
+ throw new Error(
119
+ `DSE internal error: input._bound must be a non-negative integer, got ${JSON.stringify(bound)}.`
120
+ );
121
+ }
122
+
123
+ const key = JSON.stringify(input);
124
+ if (visited.has(key)) continue;
125
+ visited.add(key);
126
+
127
+ const symInput = Object.assign({}, input);
128
+ const state = new SymbolicState(symInput, null);
129
+ state.coverage = new BabelCoverage(getIidMap());
130
+
131
+ for (const [symName, cfg] of Object.entries(symbols)) {
132
+ const concreteVal = typeof input[symName] === "number" ? input[symName] : cfg.default;
133
+ const sym = state.createSymbolicValue(symName, concreteVal);
134
+ symInput[symName] = sym;
135
+
136
+ if (cfg.min != null) {
137
+ const geq = state.binary(">=", sym, cfg.min);
138
+ const geq_s = state.asSymbolic(geq);
139
+ if (geq_s) state.pushCondition(geq_s, true);
140
+ }
141
+ if (cfg.max != null) {
142
+ const leq = state.binary("<=", sym, cfg.max);
143
+ const leq_s = state.asSymbolic(leq);
144
+ if (leq_s) state.pushCondition(leq_s, true);
145
+ }
146
+ }
147
+
148
+ setCurrentState(state);
149
+ try {
150
+ const result = fn(...args.map((a) => symInput[a]));
151
+
152
+ if (assertFn) {
153
+ const assertCond = assertFn(result);
154
+
155
+ if (!state.isSymbolic(assertCond)) {
156
+ console.warn(
157
+ `[vitest-dse] Warning: assert callback is running concretely ` +
158
+ `(returned a plain ${typeof assertCond}, not a ConcolicValue). ` +
159
+ `DSE cannot explore assertion violations. ` +
160
+ `Ensure assert is defined in a file covered by the instrumentation filter.`
161
+ );
162
+ }
163
+
164
+ const assertPassed = state.getConcrete(assertCond);
165
+
166
+ if (!assertPassed) {
167
+ const concreteInput = concretizeInput(input, state);
168
+ const vKey = JSON.stringify(concreteInput);
169
+ if (!violationKeys.has(vKey)) {
170
+ violationKeys.add(vKey);
171
+ violations.push({ input: concreteInput, result: deepConcretize(result) });
172
+ }
173
+ }
174
+
175
+ const assertSym = state.asSymbolic(assertCond);
176
+ if (assertSym) state.pushCondition(assertSym);
177
+ }
178
+ } catch (e) {
179
+ const concreteInput = concretizeInput(input, state);
180
+ const vKey = JSON.stringify(concreteInput);
181
+ if (!violationKeys.has(vKey)) {
182
+ violationKeys.add(vKey);
183
+ violations.push({ input: concreteInput, error: String(e) });
184
+ }
185
+ } finally {
186
+ setCurrentState(null);
187
+ }
188
+
189
+ state.alternatives((childInputs) => {
190
+ for (const { input: alt } of childInputs) {
191
+ if (!visited.has(JSON.stringify(alt))) queue.push(alt);
192
+ }
193
+ });
194
+ }
195
+
196
+ if (queue.length > 0 && visited.size >= maxPaths) {
197
+ console.warn(
198
+ `[vitest-dse] Budget exhausted: explored ${visited.size} paths ` +
199
+ `(maxPaths=${maxPaths}, ${queue.length} remaining). ` +
200
+ `The property has NOT been fully verified. Increase opts.maxPaths to explore further.`
201
+ );
202
+ }
203
+
204
+ runConcreteVariants(config, violations, violationKeys);
205
+
206
+ return violations;
207
+ }
208
+
209
+ // ── Concrete type-variant pass ─────────────────────────────────────────────────
210
+
211
+ /**
212
+ * Default wrong-type values tried when opts.anyType is true.
213
+ * Covers the most common runtime type bugs: null/undefined, wrong primitive, array, object.
214
+ */
215
+ const DEFAULT_TYPE_VARIANTS = [null, undefined, "", "string", NaN, [], {}];
216
+
217
+ /**
218
+ * Run the function concretely (no symbolic state) for each (symbol, typeVariant) pair.
219
+ * Each variant replaces one symbol; all others stay at their default values.
220
+ * Violations (assert failures or exceptions) are added to the shared violations/keys sets.
221
+ */
222
+ function runConcreteVariants(config, violations, violationKeys) {
223
+ const { fn, symbols, assert: assertFn } = config;
224
+ const args = config.args ?? Object.keys(symbols);
225
+
226
+ for (const [symName, cfg] of Object.entries(symbols)) {
227
+ const variants = cfg.typeVariants === "any" ? DEFAULT_TYPE_VARIANTS : (cfg.typeVariants ?? []);
228
+ for (const variantVal of variants) {
229
+ const concreteInput = {};
230
+ for (const [n, c] of Object.entries(symbols)) {
231
+ concreteInput[n] = n === symName ? variantVal : c.default;
232
+ }
233
+
234
+ const vKey = JSON.stringify(concreteInput);
235
+ if (violationKeys.has(vKey)) continue;
236
+
237
+ try {
238
+ const result = fn(...args.map((a) => concreteInput[a]));
239
+ if (assertFn) {
240
+ const passed = assertFn(result); // concrete bool — no symbolic state
241
+ if (!passed) {
242
+ violationKeys.add(vKey);
243
+ violations.push({ input: concreteInput, result: deepConcretize(result) });
244
+ }
245
+ }
246
+ } catch (e) {
247
+ violationKeys.add(vKey);
248
+ violations.push({ input: concreteInput, error: String(e) });
249
+ }
250
+ }
251
+ }
252
+ }
253
+
254
+ // ── High-level wrapper (backwards compatible) ──────────────────────────────────
255
+
256
+ /**
257
+ * Wrap dse() in a Vitest test with built-in expectation checks.
258
+ *
259
+ * @param {string} name
260
+ * @param {object} config — same as dse()
261
+ * @param {object} [opts]
262
+ * maxPaths — path budget (default 200)
263
+ * timeout — Vitest timeout ms (default 120 000)
264
+ * expectErrors — number (exact) | [min,max] | { min?, max? } (default 0)
265
+ * expectCounterexamples — array of partial input specs that must appear in violations
266
+ */
267
+ function symbolicTest(name, config, opts = {}) {
268
+ const timeout = opts.timeout ?? 120_000;
269
+
270
+ test(name, () => {
271
+ const violations = dse(config, opts);
272
+
273
+ // ── expectErrors check ────────────────────────────────────────────────────
274
+ const expectErrors = opts.expectErrors ?? 0;
275
+ let eMin, eMax;
276
+ if (Array.isArray(expectErrors)) {
277
+ [eMin, eMax] = expectErrors;
278
+ } else if (typeof expectErrors === "object" && expectErrors !== null) {
279
+ eMin = expectErrors.min ?? 0;
280
+ eMax = expectErrors.max ?? Infinity;
281
+ } else {
282
+ eMin = eMax = expectErrors;
283
+ }
284
+
285
+ const n = violations.length;
286
+ if (n < eMin || n > eMax) {
287
+ const rangeStr = eMin === eMax ? `${eMin}` : `[${eMin}, ${eMax === Infinity ? "∞" : eMax}]`;
288
+ const msg = eMin === 0 && eMax === 0
289
+ ? formatViolations(violations)
290
+ : `Expected ${rangeStr} violation(s), DSE found ${n}.\n` +
291
+ (n > 0 ? formatViolations(violations) : "No violations found.");
292
+ throw new Error(msg);
293
+ }
294
+
295
+ // ── expectCounterexamples check ───────────────────────────────────────────
296
+ for (const spec of (opts.expectCounterexamples ?? [])) {
297
+ const found = violations.some(({ input }) =>
298
+ Object.entries(spec).every(([k, v]) => input[k] === v)
299
+ );
300
+ if (!found) {
301
+ const foundInputs = violations.map(({ input }) => JSON.stringify(input)).join(", ") || "none";
302
+ throw new Error(
303
+ `DSE did not find expected counterexample ${JSON.stringify(spec)}.\n` +
304
+ `Violations found: ${foundInputs}`
305
+ );
306
+ }
307
+ }
308
+ }, timeout);
309
+ }
310
+
311
+ // ── Violation filter helpers ───────────────────────────────────────────────────
312
+
313
+ /** Violations where the function threw an exception. */
314
+ function exceptions(violations) {
315
+ return violations.filter((v) => v.error !== undefined);
316
+ }
317
+
318
+ /** Violations where the assert property failed (no exception). */
319
+ function assertFailures(violations) {
320
+ return violations.filter((v) => v.result !== undefined);
321
+ }
322
+
323
+ module.exports = { dse, symbolicTest, exceptions, assertFailures };
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@gabjauf/vitest-dse",
3
+ "version": "0.1.0",
4
+ "description": "DSE-powered property testing for Vitest using ExpoSE engine + Babel instrumentation",
5
+ "license": "MIT",
6
+ "keywords": ["vitest", "symbolic-execution", "property-testing", "dse", "expose"],
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/ExpoSEJS/ExpoSE.git",
10
+ "directory": "lib/vitest-dse"
11
+ },
12
+ "scripts": {
13
+ "prepublishOnly": "npm pack --dry-run"
14
+ },
15
+ "main": "index.js",
16
+ "exports": {
17
+ ".": "./index.js",
18
+ "./plugin": "./plugin.js",
19
+ "./setup": "./setup.js"
20
+ },
21
+ "files": [
22
+ "index.js",
23
+ "plugin.js",
24
+ "setup.js",
25
+ "runtime.js",
26
+ "babel-plugin.js",
27
+ "iid-registry.js",
28
+ "coverage.js"
29
+ ],
30
+ "peerDependencies": {
31
+ "@babel/core": ">=7",
32
+ "@babel/preset-typescript": ">=7",
33
+ "@babel/types": ">=7",
34
+ "@gabjauf/expose-analyser": ">=1",
35
+ "vitest": ">=1"
36
+ },
37
+ "dependencies": {
38
+ "vite": ">=4"
39
+ }
40
+ }
package/plugin.js ADDED
@@ -0,0 +1,70 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Vite plugin wrapping the DSE Babel instrumentation.
5
+ *
6
+ * Usage in vitest.config.symbolic.js:
7
+ * const { dsePlugin } = require('@gabjauf/vitest-dse/plugin');
8
+ * module.exports = { plugins: [dsePlugin({ include: ['src/**'] })] }
9
+ *
10
+ * Options:
11
+ * include — array of minimatch patterns; files that match are instrumented
12
+ * exclude — array of patterns to always skip (default: node_modules + vitest-dse itself)
13
+ */
14
+
15
+ const babel = require("@babel/core");
16
+ const dseBabelPlugin = require("./babel-plugin.js");
17
+ const { nextIid } = require("./iid-registry.js");
18
+ const path = require("path");
19
+ const { createFilter } = require("vite");
20
+
21
+ function dsePlugin(opts = {}) {
22
+ const include = opts.include ?? ["src/**", "tests/**"];
23
+ const exclude = opts.exclude ?? [];
24
+
25
+ const filter = createFilter(include, [
26
+ "**/node_modules/**",
27
+ ...exclude,
28
+ ]);
29
+
30
+ return {
31
+ name: "vitest-dse",
32
+
33
+ transform(code, id) {
34
+ if (!filter(id)) return null;
35
+
36
+ // Skip non-JS/TS files
37
+ if (!/\.[cm]?[jt]sx?$/.test(id)) return null;
38
+
39
+ const filename = id;
40
+ let result;
41
+ try {
42
+ result = babel.transformSync(code, {
43
+ filename,
44
+ presets: [
45
+ ["@babel/preset-typescript", { allExtensions: true }],
46
+ ],
47
+ plugins: [
48
+ [
49
+ dseBabelPlugin,
50
+ {
51
+ // Capture filename in closure; babel-plugin passes (line, col)
52
+ nextIid: (line, col) => nextIid(filename, line, col),
53
+ },
54
+ ],
55
+ ],
56
+ sourceMaps: true,
57
+ configFile: false,
58
+ babelrc: false,
59
+ });
60
+ } catch (e) {
61
+ this.warn(`vitest-dse: failed to instrument ${path.relative(process.cwd(), id)}: ${e.message}`);
62
+ return null;
63
+ }
64
+
65
+ return { code: result.code, map: result.map };
66
+ },
67
+ };
68
+ }
69
+
70
+ module.exports = { dsePlugin };
package/runtime.js ADDED
@@ -0,0 +1,237 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * DSE Runtime — exposes __DSE__ as a process global.
5
+ *
6
+ * When a SymbolicState is active (set via setCurrentState), every __DSE__
7
+ * method delegates to the state for symbolic reasoning.
8
+ *
9
+ * When no state is active (null), all methods fall through to their concrete
10
+ * implementations so that regular (non-symbolic) code still works.
11
+ */
12
+
13
+ const path = require("path");
14
+
15
+ // Resolve from Analyser/bin (compiled CJS)
16
+ const analyserBin = path.join(__dirname, "../../Analyser/bin");
17
+
18
+ const SymbolicStateModule = require(path.join(analyserBin, "SymbolicState"));
19
+ const SymbolicState = SymbolicStateModule.default || SymbolicStateModule;
20
+
21
+ const { ConcolicValue } = require(path.join(analyserBin, "Values/WrappedValue"));
22
+ const { isNative } = require(path.join(analyserBin, "Utilities/IsNative"));
23
+ const ModelBuilder = require(path.join(analyserBin, "Models/Models"));
24
+
25
+ // ── State management ──────────────────────────────────────────────────────────
26
+
27
+ // Store state on global so all module instances (Vitest creates several) share one reference.
28
+ function setCurrentState(state) {
29
+ process.__DSE_STATE__ = state;
30
+ process.__DSE_MODELS__ = state ? (ModelBuilder.default || ModelBuilder)(state) : null;
31
+ }
32
+
33
+ function getCurrentState() {
34
+ return process.__DSE_STATE__ ?? null;
35
+ }
36
+
37
+ // ── Concrete fallbacks ─────────────────────────────────────────────────────────
38
+
39
+ function concreteBinary(op, left, right) {
40
+ switch (op) {
41
+ case "+": return left + right;
42
+ case "-": return left - right;
43
+ case "*": return left * right;
44
+ case "/": return left / right;
45
+ case "%": return left % right;
46
+ case "**": return left ** right;
47
+ case "<": return left < right;
48
+ case "<=": return left <= right;
49
+ case ">": return left > right;
50
+ case ">=": return left >= right;
51
+ case "==": return left == right; // eslint-disable-line eqeqeq
52
+ case "===": return left === right;
53
+ case "!=": return left != right; // eslint-disable-line eqeqeq
54
+ case "!==": return left !== right;
55
+ case "&&": return left && right;
56
+ case "||": return left || right;
57
+ case "??": return left ?? right;
58
+ case "&": return left & right;
59
+ case "|": return left | right;
60
+ case "^": return left ^ right;
61
+ case "<<": return left << right;
62
+ case ">>": return left >> right;
63
+ case ">>>": return left >>> right;
64
+ case "in": return left in right;
65
+ case "instanceof": return left instanceof right;
66
+ default:
67
+ throw new TypeError(`[vitest-dse] Unsupported binary operator: ${op}`);
68
+ }
69
+ }
70
+
71
+ function concreteUnary(op, arg) {
72
+ switch (op) {
73
+ case "!": return !arg;
74
+ case "-": return -arg;
75
+ case "+": return +arg;
76
+ case "~": return ~arg;
77
+ case "typeof": return typeof arg;
78
+ default:
79
+ throw new TypeError(`[vitest-dse] Unsupported unary operator: ${op}`);
80
+ }
81
+ }
82
+
83
+ // ── __DSE__ global ─────────────────────────────────────────────────────────────
84
+
85
+ const __DSE__ = {
86
+ /**
87
+ * Binary operator: a OP b
88
+ */
89
+ B(iid, op, left, right) {
90
+ if (!process.__DSE_STATE__) return concreteBinary(op, left, right);
91
+ process.__DSE_STATE__.coverage.touch(iid);
92
+ return process.__DSE_STATE__.binary(op, left, right);
93
+ },
94
+
95
+ /**
96
+ * Unary operator: OP a
97
+ */
98
+ U(iid, op, arg) {
99
+ if (!process.__DSE_STATE__) return concreteUnary(op, arg);
100
+ process.__DSE_STATE__.coverage.touch(iid);
101
+ return process.__DSE_STATE__.unary(op, arg);
102
+ },
103
+
104
+ /**
105
+ * Property read: obj[key]
106
+ */
107
+ G(iid, base, offset) {
108
+ if (!process.__DSE_STATE__) {
109
+ const b = base;
110
+ return b == null ? undefined : b[offset];
111
+ }
112
+ process.__DSE_STATE__.coverage.touch(iid);
113
+
114
+ const base_c = process.__DSE_STATE__.getConcrete(base);
115
+
116
+ // SymbolicObject field read
117
+ const { SymbolicObject } = require(path.join(analyserBin, "Values/SymbolicObject"));
118
+ if (base instanceof SymbolicObject) {
119
+ return base.getField(process.__DSE_STATE__, process.__DSE_STATE__.getConcrete(offset));
120
+ }
121
+
122
+ // Symbolic string offset on concrete object
123
+ if (!process.__DSE_STATE__.isSymbolic(base) && process.__DSE_STATE__.isSymbolic(offset) && typeof process.__DSE_STATE__.getConcrete(offset) === "string") {
124
+ const offset_c = process.__DSE_STATE__.getConcrete(offset);
125
+ for (const idx in base_c) {
126
+ const test = process.__DSE_STATE__.binary("==", idx, offset);
127
+ if (process.__DSE_STATE__.asSymbolic(test)) process.__DSE_STATE__.pushCondition(process.__DSE_STATE__.asSymbolic(test));
128
+ }
129
+ return base_c[offset_c];
130
+ }
131
+
132
+ // Symbolic array index on concrete array
133
+ if (!process.__DSE_STATE__.isSymbolic(base) && process.__DSE_STATE__.isSymbolic(offset) &&
134
+ Array.isArray(base_c) && typeof process.__DSE_STATE__.getConcrete(offset) === "number") {
135
+ for (let i = 0; i < base_c.length; i++) {
136
+ process.__DSE_STATE__.assertEqual(i, offset);
137
+ }
138
+ return base_c[process.__DSE_STATE__.getConcrete(offset)];
139
+ }
140
+
141
+ // Symbolic base: use symbolicField
142
+ const result_s = process.__DSE_STATE__.isSymbolic(base)
143
+ ? process.__DSE_STATE__.symbolicField(base_c, process.__DSE_STATE__.asSymbolic(base), process.__DSE_STATE__.getConcrete(offset), process.__DSE_STATE__.asSymbolic(offset))
144
+ : undefined;
145
+ const result_c = base_c == null ? undefined : base_c[process.__DSE_STATE__.getConcrete(offset)];
146
+
147
+ return result_s ? new ConcolicValue(result_c, result_s) : result_c;
148
+ },
149
+
150
+ /**
151
+ * Property write: obj[key] = val
152
+ * Returns the value (assignment expression result).
153
+ */
154
+ P(iid, base, offset, val) {
155
+ if (!process.__DSE_STATE__) {
156
+ if (base != null) base[offset] = val;
157
+ return val;
158
+ }
159
+ process.__DSE_STATE__.coverage.touch(iid);
160
+
161
+ const base_c = process.__DSE_STATE__.getConcrete(base);
162
+ const offset_c = process.__DSE_STATE__.getConcrete(offset);
163
+ const val_c = process.__DSE_STATE__.getConcrete(val);
164
+
165
+ const { SymbolicObject } = require(path.join(analyserBin, "Values/SymbolicObject"));
166
+ if (base instanceof SymbolicObject) {
167
+ return base.setField(process.__DSE_STATE__, offset_c, val);
168
+ }
169
+
170
+ // For symbolic arrays with typed elements, track field updates
171
+ if (process.__DSE_STATE__.isSymbolic(base) && Array.isArray(base_c) && process.__DSE_STATE__.arrayType(base) === typeof val_c) {
172
+ const newArr = base_c.slice();
173
+ newArr[offset_c] = val_c;
174
+ const newSym = process.__DSE_STATE__.asSymbolic(base).setField(process.__DSE_STATE__.ctx.mkRealToInt(process.__DSE_STATE__.asSymbolic(offset)), process.__DSE_STATE__.asSymbolic(val));
175
+ const result = new ConcolicValue(newArr, newSym);
176
+ if (base_c) base_c[offset_c] = val_c;
177
+ return result;
178
+ }
179
+
180
+ if (base_c != null) base_c[offset_c] = val_c;
181
+ return val;
182
+ },
183
+
184
+ /**
185
+ * Branch condition (if/while/for/ternary)
186
+ */
187
+ C(iid, cond) {
188
+ if (!process.__DSE_STATE__) return !!cond;
189
+ process.__DSE_STATE__.coverage.touch_cnd(iid, !!process.__DSE_STATE__.getConcrete(cond));
190
+ return process.__DSE_STATE__.conditional(cond);
191
+ },
192
+
193
+ /**
194
+ * Function invocation: fn.apply(thisArg, args)
195
+ */
196
+ I(iid, fn, thisArg, args) {
197
+ if (!process.__DSE_STATE__) {
198
+ const f = fn;
199
+ const t = thisArg;
200
+ return f == null ? undefined : f.apply(t, args);
201
+ }
202
+ process.__DSE_STATE__.coverage.touch(iid);
203
+
204
+ const fn_c = process.__DSE_STATE__.getConcrete(fn);
205
+ const thisArg_c = process.__DSE_STATE__.getConcrete(thisArg);
206
+
207
+ if (fn_c == null) {
208
+ throw new TypeError("__DSE__.I: called non-function");
209
+ }
210
+
211
+ // Check for a registered model
212
+ const fn_model = process.__DSE_MODELS__ ? process.__DSE_MODELS__.get(fn_c) : null;
213
+
214
+ if (fn_model) {
215
+ // The Model registry wraps each handler as: function mdl() { return _mdl.call(null, this, arguments); }
216
+ // So models are called via fn_model.apply(thisArg, args):
217
+ // this → base (receiver)
218
+ // arguments[i] → args[i] (call arguments, possibly ConcolicValues)
219
+ return fn_model.apply(thisArg, args);
220
+ }
221
+
222
+ // Native function without model: concretize symbolic args
223
+ if (isNative(fn_c)) {
224
+ const concreteArgs = args.map(a => process.__DSE_STATE__.getConcrete(a));
225
+ const concreteThis = process.__DSE_STATE__.getConcrete(thisArg);
226
+ return fn_c.apply(concreteThis, concreteArgs);
227
+ }
228
+
229
+ // User function: call with wrapped args (symbolic values pass through)
230
+ return fn_c.apply(thisArg_c, args);
231
+ },
232
+ };
233
+
234
+ // Register as a global so instrumented code can access it without importing
235
+ global.__DSE__ = __DSE__;
236
+
237
+ module.exports = { setCurrentState, getCurrentState, __DSE__ };
package/setup.js ADDED
@@ -0,0 +1,78 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Vitest setupFile — must run before any test file.
5
+ *
6
+ * 1. Registers __DSE__ as a global so instrumented code can call it.
7
+ * 2. Patches Module._extensions to run our Babel transform on every CJS .js
8
+ * file that matches the include patterns, using the shared iid-registry so
9
+ * iids assigned here and by the Vite plugin (plugin.js) never collide.
10
+ *
11
+ * Include patterns are read from DSE_INCLUDE_PATTERNS (a JSON-encoded string
12
+ * array set by vitest.config.symbolic.js) so the filter always matches what
13
+ * the Vite plugin was configured with.
14
+ */
15
+
16
+ require("./runtime");
17
+
18
+ const Module = require("module");
19
+ const fs = require("fs");
20
+ const babel = require("@babel/core");
21
+ const dseBabelPlugin = require("./babel-plugin.js");
22
+ const { nextIid } = require("./iid-registry.js");
23
+ const { createFilter } = require("vite");
24
+
25
+ // ── Filter configuration ───────────────────────────────────────────────────
26
+ // Read patterns from the env var set by vitest.config.symbolic.js so this
27
+ // file and the Vite plugin always agree on what to instrument.
28
+ const include = process.env.DSE_INCLUDE_PATTERNS
29
+ ? JSON.parse(process.env.DSE_INCLUDE_PATTERNS)
30
+ : ["src/**", "tests/examples/**"]; // fallback for standalone use
31
+
32
+ const filter = createFilter(include, [
33
+ "**/node_modules/**",
34
+ ]);
35
+
36
+ // ── Module._extensions hook ────────────────────────────────────────────────
37
+
38
+ function instrumentAndLoad(mod, filename, fallback) {
39
+ if (!filter(filename)) return fallback(mod, filename);
40
+
41
+ const source = fs.readFileSync(filename, "utf8");
42
+
43
+ // If the file was already transformed by the Vite plugin (i.e. it came
44
+ // through the Vite transform pipeline before being require()'d), skip it
45
+ // to avoid double-instrumentation.
46
+ if (source.includes("__DSE__")) return fallback(mod, filename);
47
+
48
+ let result;
49
+ try {
50
+ result = babel.transformSync(source, {
51
+ filename,
52
+ presets: [
53
+ ["@babel/preset-typescript", { allExtensions: true }],
54
+ ],
55
+ plugins: [
56
+ [
57
+ dseBabelPlugin,
58
+ {
59
+ nextIid: (line, col) => nextIid(filename, line, col),
60
+ },
61
+ ],
62
+ ],
63
+ sourceMaps: "inline",
64
+ configFile: false,
65
+ babelrc: false,
66
+ });
67
+ } catch (e) {
68
+ return fallback(mod, filename);
69
+ }
70
+
71
+ mod._compile(result.code, filename);
72
+ }
73
+
74
+ const originalJsLoader = Module._extensions[".js"];
75
+ Module._extensions[".js"] = (mod, filename) => instrumentAndLoad(mod, filename, originalJsLoader);
76
+
77
+ const originalTsLoader = Module._extensions[".ts"] || originalJsLoader;
78
+ Module._extensions[".ts"] = (mod, filename) => instrumentAndLoad(mod, filename, originalTsLoader);