vimonkey 0.2.2 → 0.2.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.
@@ -1,115 +0,0 @@
1
- /**
2
- * Shrinking - find minimal failing sequence via delta debugging
3
- *
4
- * Uses binary search to reduce a failing sequence to the smallest
5
- * subsequence that still triggers the failure.
6
- */
7
-
8
- export interface ShrinkOptions {
9
- /** Maximum shrinking attempts (default: 100) */
10
- maxAttempts?: number
11
- /** Minimum sequence length to try (default: 1) */
12
- minLength?: number
13
- }
14
-
15
- export interface ShrinkResult<T> {
16
- /** Original sequence */
17
- original: T[]
18
- /** Shrunk (minimal) sequence */
19
- shrunk: T[]
20
- /** Number of shrinking attempts made */
21
- attempts: number
22
- /** Whether shrinking found a smaller sequence */
23
- reduced: boolean
24
- }
25
-
26
- /**
27
- * Shrink a failing sequence to its minimal form
28
- *
29
- * Uses delta debugging algorithm:
30
- * 1. Try removing first half - if still fails, keep only first half
31
- * 2. Try removing second half - if still fails, keep only second half
32
- * 3. Try removing individual elements from start/end
33
- * 4. Repeat until no reduction possible
34
- *
35
- * @param sequence - The original failing sequence
36
- * @param runTest - Function that returns true if sequence passes, false if fails
37
- * @param options - Shrinking options
38
- */
39
- export async function shrinkSequence<T>(
40
- sequence: T[],
41
- runTest: (seq: T[]) => Promise<boolean>,
42
- options: ShrinkOptions = {},
43
- ): Promise<ShrinkResult<T>> {
44
- const { maxAttempts = 100, minLength = 1 } = options
45
-
46
- let current = [...sequence]
47
- let attempts = 0
48
- let changed = true
49
-
50
- while (changed && attempts < maxAttempts && current.length > minLength) {
51
- changed = false
52
-
53
- // Try removing first half
54
- if (current.length > 1) {
55
- const half = Math.ceil(current.length / 2)
56
- const secondHalf = current.slice(half)
57
- attempts++
58
-
59
- if (secondHalf.length >= minLength && !(await runTest(secondHalf))) {
60
- // Second half alone still fails
61
- current = secondHalf
62
- changed = true
63
- continue
64
- }
65
- }
66
-
67
- // Try removing second half
68
- if (current.length > 1) {
69
- const half = Math.floor(current.length / 2)
70
- const firstHalf = current.slice(0, half)
71
- attempts++
72
-
73
- if (firstHalf.length >= minLength && !(await runTest(firstHalf))) {
74
- // First half alone still fails
75
- current = firstHalf
76
- changed = true
77
- continue
78
- }
79
- }
80
-
81
- // Try removing individual elements
82
- for (let i = 0; i < current.length && attempts < maxAttempts; i++) {
83
- const without = [...current.slice(0, i), ...current.slice(i + 1)]
84
- attempts++
85
-
86
- if (without.length >= minLength && !(await runTest(without))) {
87
- // Removing element i still fails
88
- current = without
89
- changed = true
90
- break
91
- }
92
- }
93
- }
94
-
95
- return {
96
- original: sequence,
97
- shrunk: current,
98
- attempts,
99
- reduced: current.length < sequence.length,
100
- }
101
- }
102
-
103
- /**
104
- * Format shrink result for display
105
- */
106
- export function formatShrinkResult<T>(result: ShrinkResult<T>): string {
107
- const reduction = result.original.length - result.shrunk.length
108
- const percent = Math.round((reduction / result.original.length) * 100)
109
-
110
- if (!result.reduced) {
111
- return `Could not reduce sequence (${result.original.length} steps, ${result.attempts} attempts)`
112
- }
113
-
114
- return `Shrunk from ${result.original.length} to ${result.shrunk.length} steps (${percent}% reduction, ${result.attempts} attempts)`
115
- }
@@ -1,241 +0,0 @@
1
- /**
2
- * test.fuzz wrapper with auto-tracking, shrinking, and regression
3
- *
4
- * Usage:
5
- * ```typescript
6
- * import { test } from 'vimonkey/fuzz'
7
- *
8
- * test.fuzz('cursor invariants', async () => {
9
- * const handle = await run(<Board />, { cols: 80, rows: 24 })
10
- * for await (const key of take(gen(['j','k','h','l']), 100)) {
11
- * await handle.press(key)
12
- * expect(handle.locator('[data-cursor]').count()).toBe(1)
13
- * }
14
- * })
15
- * ```
16
- */
17
-
18
- import { test as vitestTest, type TestOptions } from "vitest"
19
- import { fuzzContext, createFuzzContext, createReplayContext, type FuzzContext } from "./context.js"
20
- import { shrinkSequence, formatShrinkResult } from "./shrink.js"
21
- import { saveCase, loadCasesForTest, type SavedCase } from "./regression.js"
22
- import { parseSeed, parseRepeats, deriveSeeds } from "../random.js"
23
-
24
- /** Options for test.fuzz */
25
- export interface FuzzTestOptions extends TestOptions {
26
- /** Seed for reproducibility (default: from FUZZ_SEED env or random) */
27
- seed?: number
28
- /** Whether to shrink failing sequences (default: true) */
29
- shrink?: boolean
30
- /** Whether to save failing sequences to __fuzz_cases__/ (default: true) */
31
- save?: boolean
32
- /** Whether to replay saved failing sequences first (default: true) */
33
- replay?: boolean
34
- /** Maximum shrinking attempts (default: 100) */
35
- maxShrinkAttempts?: number
36
- }
37
-
38
- /** Error with fuzz context attached */
39
- export class FuzzError extends Error {
40
- readonly sequence: unknown[]
41
- readonly seed: number
42
- readonly originalError: Error
43
-
44
- constructor(
45
- originalError: Error,
46
- info: {
47
- original: number
48
- shrunk: number
49
- sequence: unknown[]
50
- seed: number
51
- },
52
- ) {
53
- const msg = `Fuzz test failed after ${info.original} steps (shrunk to ${info.shrunk})
54
- Minimal failing sequence: ${JSON.stringify(info.sequence)}
55
- Seed: ${info.seed} (reproduce with FUZZ_SEED=${info.seed})
56
-
57
- Original error: ${originalError.message}`
58
-
59
- super(msg)
60
- this.name = "FuzzError"
61
- this.sequence = info.sequence
62
- this.seed = info.seed
63
- this.originalError = originalError
64
- }
65
- }
66
-
67
- /**
68
- * Get the test file path from error stack
69
- * This is a heuristic - it looks for .test.ts or .fuzz.ts files
70
- */
71
- function getTestFilePath(): string {
72
- const err = new Error()
73
- const stack = err.stack?.split("\n") ?? []
74
-
75
- for (const line of stack) {
76
- // Look for test file patterns
77
- const match = line.match(/\(([^)]+\.(test|fuzz)\.(ts|tsx|js|jsx)):\d+:\d+\)/)
78
- if (match) return match[1]!
79
-
80
- // Also try without parentheses
81
- const match2 = line.match(/at\s+([^\s]+\.(test|fuzz)\.(ts|tsx|js|jsx)):\d+:\d+/)
82
- if (match2) return match2[1]!
83
- }
84
-
85
- // Fallback to current working directory
86
- return process.cwd() + "/unknown.test.ts"
87
- }
88
-
89
- /**
90
- * Run a single fuzz test body with a specific seed.
91
- * Handles replay, shrinking, and saving of failing cases.
92
- */
93
- async function runFuzzBody(
94
- name: string,
95
- fn: () => Promise<void>,
96
- seed: number,
97
- opts: { shrink: boolean; save: boolean; replay: boolean; maxShrinkAttempts: number },
98
- ) {
99
- const testFilePath = getTestFilePath()
100
-
101
- // Replay saved failing sequences first
102
- if (opts.replay) {
103
- const savedCases = loadCasesForTest(testFilePath, name)
104
- for (const savedCase of savedCases) {
105
- const replayCtx = createReplayContext(savedCase.sequence, savedCase.seed)
106
- try {
107
- await fuzzContext.run(replayCtx, fn)
108
- // If replay passes, the bug might be fixed - but we still run the main test
109
- } catch (error) {
110
- // Replay still fails - throw with saved context
111
- throw new FuzzError(error as Error, {
112
- original: savedCase.originalLength ?? savedCase.sequence.length,
113
- shrunk: savedCase.sequence.length,
114
- sequence: savedCase.sequence,
115
- seed: savedCase.seed,
116
- })
117
- }
118
- }
119
- }
120
-
121
- // Run the main fuzz test
122
- const ctx = createFuzzContext(seed)
123
-
124
- try {
125
- await fuzzContext.run(ctx, fn)
126
- } catch (error) {
127
- // Test failed - attempt shrinking
128
- if (ctx.history.length > 0) {
129
- let minimalSequence = ctx.history
130
- let shrinkResult
131
-
132
- if (opts.shrink) {
133
- // Define the test runner for shrinking
134
- const runWithSequence = async (seq: unknown[]) => {
135
- const replayCtx = createReplayContext(seq, seed)
136
- try {
137
- await fuzzContext.run(replayCtx, fn)
138
- return true // passed
139
- } catch {
140
- return false // still fails
141
- }
142
- }
143
-
144
- shrinkResult = await shrinkSequence(ctx.history, runWithSequence, {
145
- maxAttempts: opts.maxShrinkAttempts,
146
- })
147
- minimalSequence = shrinkResult.shrunk
148
-
149
- // Log shrink result
150
- console.log(formatShrinkResult(shrinkResult))
151
- }
152
-
153
- // Save failing case
154
- if (opts.save) {
155
- const savedCase: SavedCase = {
156
- test: name,
157
- seed,
158
- sequence: minimalSequence,
159
- error: String(error),
160
- timestamp: new Date().toISOString(),
161
- originalLength: ctx.history.length,
162
- }
163
- const filepath = saveCase(testFilePath, name, savedCase)
164
- console.log(`Saved failing case to: ${filepath}`)
165
- }
166
-
167
- throw new FuzzError(error as Error, {
168
- original: ctx.history.length,
169
- shrunk: minimalSequence.length,
170
- sequence: minimalSequence,
171
- seed,
172
- })
173
- }
174
-
175
- throw error
176
- }
177
- }
178
-
179
- /**
180
- * Create the test.fuzz wrapper
181
- *
182
- * When FUZZ_REPEATS > 1, registers multiple vitest tests — one per seed —
183
- * so each gets its own result in the reporter and failures are independently
184
- * visible. Seeds are deterministically derived from the base seed.
185
- */
186
- function createFuzzTest(name: string, fn: () => Promise<void>, options: FuzzTestOptions = {}) {
187
- const {
188
- seed = parseSeed("env"),
189
- shrink = true,
190
- save = true,
191
- replay = true,
192
- maxShrinkAttempts = 100,
193
- ...testOptions
194
- } = options
195
-
196
- const repeats = parseRepeats()
197
- const bodyOpts = { shrink, save, replay, maxShrinkAttempts }
198
-
199
- if (repeats <= 1) {
200
- // Single run (default) — original behavior
201
- return vitestTest(name, testOptions, async () => {
202
- await runFuzzBody(name, fn, seed, bodyOpts)
203
- })
204
- }
205
-
206
- // Multiple runs — register one test per seed
207
- const seeds = deriveSeeds(seed, repeats)
208
- for (let i = 0; i < seeds.length; i++) {
209
- const s = seeds[i]!
210
- vitestTest(`${name} [seed=${s}]`, testOptions, async () => {
211
- await runFuzzBody(name, fn, s, bodyOpts)
212
- })
213
- }
214
- }
215
-
216
- // Type for the fuzz function
217
- type FuzzFn = {
218
- (name: string, fn: () => Promise<void>, options?: FuzzTestOptions): void
219
- (name: string, options: FuzzTestOptions, fn: () => Promise<void>): void
220
- }
221
-
222
- // Create the fuzz function with overloads
223
- const fuzz: FuzzFn = (
224
- name: string,
225
- fnOrOptions: (() => Promise<void>) | FuzzTestOptions,
226
- optionsOrFn?: FuzzTestOptions | (() => Promise<void>),
227
- ) => {
228
- if (typeof fnOrOptions === "function") {
229
- return createFuzzTest(name, fnOrOptions, optionsOrFn as FuzzTestOptions)
230
- } else {
231
- return createFuzzTest(name, optionsOrFn as () => Promise<void>, fnOrOptions)
232
- }
233
- }
234
-
235
- /**
236
- * Extended test object with fuzz method
237
- */
238
- export const test: typeof vitestTest & { fuzz: typeof fuzz } = Object.assign(vitestTest, { fuzz })
239
-
240
- // Re-export vitest test types
241
- export { describe, expect, it, beforeAll, afterAll, beforeEach, afterEach } from "vitest"
package/src/index.ts DELETED
@@ -1,37 +0,0 @@
1
- /**
2
- * vimonkey — Fuzz testing and chaos streams for Vitest
3
- */
4
-
5
- // Fuzz API (primary)
6
- export { gen, take, type Picker, type PickerContext } from "./fuzz/gen.js"
7
- export { test, FuzzError, type FuzzTestOptions } from "./fuzz/test-fuzz.js"
8
- export { describe, expect, it, beforeAll, afterAll, beforeEach, afterEach } from "./fuzz/test-fuzz.js"
9
- export {
10
- fuzzContext,
11
- getFuzzContext,
12
- isInFuzzContext,
13
- createFuzzContext,
14
- createReplayContext,
15
- type FuzzContext,
16
- } from "./fuzz/context.js"
17
- export { shrinkSequence, formatShrinkResult, type ShrinkOptions, type ShrinkResult } from "./fuzz/shrink.js"
18
- export {
19
- saveCase,
20
- loadCases,
21
- loadCasesForTest,
22
- deleteCase,
23
- clearCases,
24
- getFuzzCasesDir,
25
- type SavedCase,
26
- } from "./fuzz/regression.js"
27
-
28
- // Utilities
29
- export {
30
- createSeededRandom,
31
- weightedPickFromTuples,
32
- parseSeed,
33
- parseRepeats,
34
- deriveSeeds,
35
- type SeededRandom,
36
- } from "./random.js"
37
- export { getTestSys, type TestSys } from "./env.js"
package/src/plugin.ts DELETED
@@ -1,96 +0,0 @@
1
- /**
2
- * @todo Planned vitest plugin — stub only. Implement config injection and
3
- * custom CLI mode interception (vitest fuzz, vitest ai, vitest doc).
4
- *
5
- * Vitest plugin for vimonkey
6
- *
7
- * @example
8
- * ```typescript
9
- * // vitest.config.ts
10
- * import { defineConfig } from 'vitest/config'
11
- * import { viMonkey } from 'vimonkey/plugin'
12
- *
13
- * export default defineConfig({
14
- * plugins: [
15
- * viMonkey({
16
- * fuzz: { iterations: 100 },
17
- * ai: { model: 'claude-sonnet' },
18
- * doc: { pattern: '**\/*.test.md' },
19
- * })
20
- * ]
21
- * })
22
- * ```
23
- */
24
-
25
- /** Minimal Vite plugin interface — avoids requiring vite as a dependency */
26
- interface Plugin {
27
- name: string
28
- config?(): void
29
- configureServer?(server: unknown): void
30
- }
31
-
32
- export interface ViMonkeyFuzzOptions {
33
- /** Number of actions per test run (default: 100) */
34
- iterations?: number
35
- /** Seed source: 'env' reads FUZZ_SEED, 'random' generates new (default: 'env') */
36
- seed?: "env" | "random"
37
- /** Stop after first failure (default: true) */
38
- failFast?: boolean
39
- /** Shrinking settings */
40
- shrink?: {
41
- enabled?: boolean
42
- maxAttempts?: number
43
- }
44
- }
45
-
46
- export interface ViMonkeyAiOptions {
47
- /** Model identifier (Vercel AI SDK format) */
48
- model?: string
49
- /** Temperature for LLM (0 = deterministic) */
50
- temperature?: number
51
- /** Maximum actions per exploration */
52
- maxSteps?: number
53
- /** Token budget per file */
54
- maxTokens?: number
55
- /** Directory to save discovered tests */
56
- saveDir?: string
57
- /** Use Claude Code provider */
58
- provider?: "openai" | "anthropic" | "claude-code"
59
- }
60
-
61
- export interface ViMonkeyDocOptions {
62
- /** Glob pattern for mdtest files */
63
- pattern?: string
64
- }
65
-
66
- export interface ViMonkeyOptions {
67
- /** Fuzz mode configuration */
68
- fuzz?: ViMonkeyFuzzOptions
69
- /** AI mode configuration */
70
- ai?: ViMonkeyAiOptions
71
- /** Doc mode configuration (mdtest) */
72
- doc?: ViMonkeyDocOptions
73
- }
74
-
75
- /**
76
- * Creates the vimonkey plugin for Vitest
77
- */
78
- export function viMonkey(options: ViMonkeyOptions = {}): Plugin {
79
- const { fuzz = {}, ai = {}, doc = {} } = options
80
-
81
- return {
82
- name: "vimonkey",
83
-
84
- config() {
85
- // Configure vitest for custom modes
86
- // Returns void for now - will be populated as modes are implemented
87
- },
88
-
89
- configureServer(server) {
90
- // Handle custom CLI modes: vitest fuzz, vitest ai, vitest doc
91
- // Implementation will intercept vitest CLI commands
92
- },
93
- }
94
- }
95
-
96
- export default viMonkey
package/src/random.ts DELETED
@@ -1,181 +0,0 @@
1
- /**
2
- * Seeded random number generator
3
- *
4
- * Uses a Linear Congruential Generator (LCG) for reproducible random sequences.
5
- * Ported from km's chaos testing infrastructure.
6
- */
7
-
8
- export interface SeededRandom {
9
- /** Get the current seed */
10
- seed: number
11
-
12
- /** Generate a random integer in [min, max] inclusive */
13
- int(min: number, max: number): number
14
-
15
- /** Generate a random float in [0, 1) */
16
- float(): number
17
-
18
- /** Pick a random element from an array */
19
- pick<T>(array: readonly T[]): T
20
-
21
- /** Pick a random element with weights */
22
- weightedPick<T extends string>(items: readonly T[], weights: Partial<Record<T, number>>): T
23
-
24
- /** Shuffle an array (returns new array) */
25
- shuffle<T>(array: readonly T[]): T[]
26
-
27
- /** Generate a random array */
28
- array<T>(length: number, generator: () => T): T[]
29
-
30
- /** Generate a random boolean with given probability of true */
31
- bool(probability?: number): boolean
32
-
33
- /** Fork the RNG (create independent stream) */
34
- fork(): SeededRandom
35
- }
36
-
37
- // Linear Congruential Generator constants (same as glibc's rand()).
38
- // Full period of 2^31. See Numerical Recipes §7.1 or Knuth TAOCP Vol 2 §3.2.1.
39
- const LCG_A = 1103515245 // multiplier
40
- const LCG_C = 12345 // increment
41
- const LCG_M = 2 ** 31 // modulus (2^31)
42
-
43
- /**
44
- * Pick a random element from weighted tuples using a pre-generated random float.
45
- *
46
- * @param tuples - Array of [weight, value] pairs
47
- * @param rand - Random float in [0, 1)
48
- * @returns The selected value
49
- */
50
- export function weightedPickFromTuples<T>(tuples: readonly (readonly [number, T])[], rand: number): T {
51
- let total = 0
52
- for (const [w] of tuples) total += w
53
- let r = rand * total
54
- for (const [weight, value] of tuples) {
55
- r -= weight
56
- if (r <= 0) return value
57
- }
58
- return tuples[tuples.length - 1]![1]
59
- }
60
-
61
- /**
62
- * Create a seeded random number generator
63
- *
64
- * @param seed - Initial seed (uses Date.now() if not provided)
65
- * @returns Seeded random number generator
66
- *
67
- * @example
68
- * ```typescript
69
- * const random = createSeededRandom(12345)
70
- *
71
- * // Same seed = same sequence
72
- * random.int(0, 100) // always 47 with seed 12345
73
- * random.pick(['a', 'b', 'c']) // always 'b' with seed 12345
74
- * ```
75
- */
76
- export function createSeededRandom(seed?: number): SeededRandom {
77
- let state = seed ?? Date.now()
78
-
79
- function next(): number {
80
- state = (LCG_A * state + LCG_C) % LCG_M
81
- return state
82
- }
83
-
84
- const random: SeededRandom = {
85
- get seed() {
86
- return state
87
- },
88
-
89
- float(): number {
90
- return next() / LCG_M
91
- },
92
-
93
- int(min: number, max: number): number {
94
- return Math.floor(random.float() * (max - min + 1)) + min
95
- },
96
-
97
- pick<T>(array: readonly T[]): T {
98
- if (array.length === 0) {
99
- throw new Error("Cannot pick from empty array")
100
- }
101
- return array[random.int(0, array.length - 1)]!
102
- },
103
-
104
- weightedPick<T extends string>(items: readonly T[], weights: Partial<Record<T, number>>): T {
105
- const tuples: [number, T][] = items.map((item) => [weights[item] ?? 1, item])
106
- return weightedPickFromTuples(tuples, random.float())
107
- },
108
-
109
- shuffle<T>(array: readonly T[]): T[] {
110
- const result = [...array]
111
- for (let i = result.length - 1; i > 0; i--) {
112
- const j = random.int(0, i)
113
- ;[result[i], result[j]] = [result[j]!, result[i]!]
114
- }
115
- return result
116
- },
117
-
118
- array<T>(length: number, generator: () => T): T[] {
119
- return Array.from({ length }, generator)
120
- },
121
-
122
- bool(probability = 0.5): boolean {
123
- return random.float() < probability
124
- },
125
-
126
- fork(): SeededRandom {
127
- // Use current state to seed new generator
128
- return createSeededRandom(next())
129
- },
130
- }
131
-
132
- return random
133
- }
134
-
135
- /**
136
- * Parse seed from environment or generate random
137
- */
138
- export function parseSeed(source: "env" | "random" = "env"): number {
139
- if (source === "env") {
140
- const envSeed = process.env.FUZZ_SEED
141
- if (envSeed) {
142
- const parsed = parseInt(envSeed, 10)
143
- if (!isNaN(parsed)) {
144
- return parsed
145
- }
146
- console.warn(`Invalid FUZZ_SEED: "${envSeed}", using random seed`)
147
- }
148
- }
149
- return Date.now()
150
- }
151
-
152
- /**
153
- * Parse FUZZ_REPEATS from environment (default: 1)
154
- *
155
- * Controls how many times each test.fuzz() test runs with different seeds.
156
- * Use FUZZ_REPEATS=10000 for CI nightly runs.
157
- */
158
- export function parseRepeats(): number {
159
- const env = process.env.FUZZ_REPEATS
160
- if (env) {
161
- const parsed = parseInt(env, 10)
162
- if (!isNaN(parsed) && parsed > 0) {
163
- return parsed
164
- }
165
- console.warn(`Invalid FUZZ_REPEATS: "${env}", using 1`)
166
- }
167
- return 1
168
- }
169
-
170
- /**
171
- * Derive N unique seeds from a base seed using LCG
172
- */
173
- export function deriveSeeds(baseSeed: number, count: number): number[] {
174
- const seeds: number[] = []
175
- let state = baseSeed
176
- for (let i = 0; i < count; i++) {
177
- state = (LCG_A * state + LCG_C) % LCG_M
178
- seeds.push(state)
179
- }
180
- return seeds
181
- }