mock-to-internals-migration · diff
v4 to v5
73 added, 250 removed. Audit A to A.
---
name: mock-to-internals-migration
- description: >
- Apply when converting test files from mock.module or vi.spyOn to _internals DI seam pattern. While examples
- use spawnSync from node:child_process, the pattern applies to ANY function needing test injection
- (readFileSync, fetch, execGit, etc.). Guides the complete migration: adding functions to _internals in source
- files, converting test files, adding proper beforeEach/afterEach save/restore lifecycle, mockReset() cleanup,
- and temp directory cleanup. Prevents mock.module and vi.spyOn leaks across Bun's shared test-runner process.
- effort: medium
- generated_from_knowledge: []
- source_knowledge_ids: ['00cc0fba-5ed8-4e2c-9a3c-96675e09d175', 'cc366a49-f097-41a5-847b-ccce077e6c80']
- generated_at: 2026-06-14T16:50:00Z
- confidence: 0.8
+ description: mock-to-internals-migration
+ triggers:
+ - opt-in tool map
+ - feature flag gating
+ - tool registration
+ generated_from_knowledge:
+ - 15870ee2-0813-406f-afd6-f34a05343584
+ - c3bbb12e-f8de-429d-9d3a-983e7929786c
+ - ece661ed-3626-4e1d-b95b-b6ab96a059e9
+ - 4d22c8a2-335b-46bd-91da-7968306c6107
+ - a152c109-c6f1-4338-8e18-1b98d2d358cf
+ - 1baf97e1-b58e-4287-acdd-7b8c150b0e6b
+ - 939330c8-be3d-4c11-842f-4a93433b7d1a
+ - c66e7212-82ca-4b03-ad06-fece8199e401
+ - 2041997b-c527-4cc0-bcf1-08f0cfd6777f
+ - d07cf04b-05c2-4e80-b291-e2d0446d9d09
+ source_knowledge_ids:
+ - 15870ee2-0813-406f-afd6-f34a05343584
+ - c3bbb12e-f8de-429d-9d3a-983e7929786c
+ - ece661ed-3626-4e1d-b95b-b6ab96a059e9
+ - 4d22c8a2-335b-46bd-91da-7968306c6107
+ - a152c109-c6f1-4338-8e18-1b98d2d358cf
+ - 1baf97e1-b58e-4287-acdd-7b8c150b0e6b
+ - 939330c8-be3d-4c11-842f-4a93433b7d1a
+ - c66e7212-82ca-4b03-ad06-fece8199e401
+ - 2041997b-c527-4cc0-bcf1-08f0cfd6777f
+ - d07cf04b-05c2-4e80-b291-e2d0446d9d09
+ generated_at: 2026-07-02T02:35:49.991Z
+ confidence: 0.60
status: active
- version: 4
+ version: 5
skill_origin: generated
- provenance_note: >
- Re-linked to current knowledge entries (version 4). The original source ID
- 906f700a... is no longer present in the active knowledge store. The skill
- body and behavior are unchanged; only the source_knowledge_ids metadata
- was updated to point to current lessons about lint-script false positives
- (testing) and upgrade-safety test helpers (testing), which are both
- directly relevant to the mock-to-internals-migration domain.
---
- # mock.module / vi.spyOn → _internals DI Seam Migration Protocol
-
- Follow every step in order. Do not skip steps.
-
- ## When to use this skill
-
- - A test file uses `mock.module('node:child_process')` or `mock.module('node:fs')` or similar
- - A test file uses `vi.spyOn(module, 'functionName')` on a module's exported functions (same anti-pattern as `mock.module` — unreliable across Bun's shared test runner when the module is split or functions move)
- - The production code already has an `_internals` export (or needs one added)
- - The goal is to eliminate `mock.module` usage per AGENTS.md invariant 7 and the writing-tests skill
-
- **Benefit:** This migration also sidesteps the Windows EBUSY risk documented in the writing-tests skill — tests using `_internals` seams do not need `mock.restore()`, which on Windows can conflict with async child process handles.
-
- ## Step 0 — Identify the target module and its _internals seam
-
- 1. Find where the test imports from:
- ```typescript
- import { _internals as engineInternals } from '../../mutation/engine.js';
- ```
- 2. Read the source module (e.g., `src/mutation/engine.ts`)
- 3. Check if `_internals` already exists:
- ```typescript
- export const _internals = {
- existingHelper,
- // other entries...
- };
- ```
- 4. Identify which function/method the mock.module was intercepting (usually `spawnSync`, `execFileSync`, `readFileSync`, etc.)
-
- ## Step 0b — Check ALL test files consuming the target module
-
- **CRITICAL:** Before converting any test file, identify EVERY test file that imports or spies on the target module. Cross-file mock leakage in Bun's shared test runner means a migration in one file can silently break tests in another file.
-
- ```bash
- # Find all test files that import the module
- grep -rln "from.*<source-module>" src/ tests/ --include="*.test.ts"
-
- # Find all test files that spy on the module
- grep -rln "vi.spyOn.*<source-module>" src/ tests/ --include="*.test.ts"
- grep -rln "spyOn.*<source-module>" src/ tests/ --include="*.test.ts"
-
- # Find all test files that mock the module
- grep -rln "mock.module.*<source-module>" src/ tests/ --include="*.test.ts"
- ```
-
- Prefer the `imports` tool for comprehensive discovery — grep misses dynamic imports and re-exports.
-
- Record every file found. ALL of them must pass after migration — not just the one being explicitly converted.
-
- ## Step 1 — Add the function to _internals in the source file
-
- **CRITICAL:** Do NOT import type aliases from Node.js built-ins (e.g., `type SpawnSyncFn` from `node:child_process`). Use `typeof` instead. This applies to ALL Node built-in functions, not just spawnSync.
-
- ```typescript
- // BAD — fails build
- import { spawnSync, type SpawnSyncFn } from 'node:child_process';
-
- // GOOD — works at build time
- import { spawnSync } from 'node:child_process';
- type SpawnSyncFn = typeof spawnSync;
- ```
-
- Add the function to `_internals`:
- ```typescript
- export const _internals: {
- executeMutation: typeof executeMutation;
- computeReport: typeof computeReport;
- executeMutationSuite: typeof executeMutationSuite;
- spawnSync: SpawnSyncFn; // ← ADD THIS
- } = {
- executeMutation,
- computeReport,
- executeMutationSuite,
- spawnSync, // ← ADD THIS
- } as const;
- ```
-
- > **Note:** The explicit type annotation on `_internals` (the `{ ... }` shape on the left side of `=`) overrides `as const` readonly inference. If you omit the explicit type annotation, `as const` will make properties `readonly` and test injection (`engineInternals.spawnSync = mockSpawnSync`) will fail at TypeScript compile time. Always include the explicit type annotation.
-
- Replace all direct calls in the module with `_internals.spawnSync(...)`:
- ```typescript
- // BEFORE
- const result = spawnSync('git', ['apply', patchFile], { cwd: workingDir });
-
- // AFTER
- const result = _internals.spawnSync('git', ['apply', patchFile], { cwd: workingDir });
- ```
-
- **Verify:** Run `bun run build` to ensure the type alias compiles.
-
- ## Step 2 — Convert the test file
-
- ### 2a. Remove mock.module block
-
- Delete the entire `mock.module(...)` block and any related mock setup.
-
- ### 2b. Add module-level variables for save/restore
-
- ```typescript
- // Module-level reference to the original function (saved/restored in beforeEach/afterEach)
- let originalSpawnSync: typeof import('node:child_process').spawnSync;
-
- // Module-level mock that logs calls and delegates to original
- const mockSpawnSync = mock(
- (cmd: string, args: string[], opts: Record<string, unknown>) => {
- spawnCallLog.push({ cmd, args, opts: { ...opts } });
- if (originalSpawnSync) {
- return originalSpawnSync(cmd, args, opts);
- }
- return {
- pid: 12345,
- output: Buffer.alloc(0),
- stdout: Buffer.from('ok'),
- stderr: Buffer.alloc(0),
- status: 0,
- signal: null,
- error: undefined,
- } as ReturnType<typeof import('node:child_process').spawnSync>;
- },
- );
- ```
-
- ### 2c. Update beforeEach
-
- ```typescript
- beforeEach(() => {
- // Save original and replace with mock
- originalSpawnSync = engineInternals.spawnSync;
- engineInternals.spawnSync = mockSpawnSync;
- spawnCallLog.length = 0;
- tempDir = makeTempDir();
- });
- ```
-
- ### 2d. Update afterEach
+ <!-- generated by opencode-swarm skill-generator. Do not edit by hand; edits will be preserved on regeneration only with controlled update mode. -->
- **CRITICAL:** Must include ALL of these in order:
- 1. Restore original function
- 2. Call `mockReset()` to clear mockImplementation state
- 3. Clean up temp directories
- 4. Clear call logs
+ # mock-to-internals-migration
- ```typescript
- afterEach(() => {
- // 1. Restore original
- engineInternals.spawnSync = originalSpawnSync;
- // 2. Reset mock implementation to prevent leak between tests
- mockSpawnSync.mockReset();
- // 3. Clean up temp directory
- if (tempDir) {
- rmSync(tempDir, { recursive: true, force: true });
- }
- // 4. Clear call log
- spawnCallLog.length = 0;
- });
- ```
+ ## Trigger
- **WARNING:** Omitting `mockReset()` causes `mockImplementation` state from one test to leak into the next test's active window.
+ - opt-in tool map
+ - feature flag gating
+ - tool registration
- ### 2e. Update test assertions
+ ## Required Procedure
- Replace assertions that checked `mockSpawnSync.mock.calls` with assertions that check `spawnCallLog`:
- ```typescript
- // BEFORE (with mock.module)
- expect(mockSpawnSync).toHaveBeenCalledWith('git', ['apply', '<patch-file>']);
+ - mirror existing opt-in pattern: separate tool map constant, conditional merge in agents/index.ts, gating tests verifying disabled and enabled states
+ - run barrel-import-dependent tests after modifying barrel exports
+ - audit code structure integrity before editing a file that was recently refactored or extracted
+ - grep for all vi.fn(), vi.spyOn(), vi.mock() in converted files before declaring conversion complete
+ - spread real module exports as base of every mock.module return object
+ - Load .opencode/skills/writing-tests/SKILL.md before touching any test file
+ - run lint check (e.g. bun run lint or equivalent) as part of pre-submit verification, not just build
+ - check for existing wrapper patterns before applying sandbox wrapping
+ - design command-transformation pipelines as idempotent passes, not stacking layers
+ - run tests on the unmodified codebase before starting to establish a failure baseline
- // AFTER (with _internals seam)
- const gitCalls = spawnCallLog.filter(c => c.cmd === 'git');
- expect(gitCalls.length).toBeGreaterThan(0);
- ```
+ ## Forbidden Shortcuts
- ## Step 3 — Verify no mock.module remains
+ - invent new tool-gating mechanisms for opt-in feature flags
+ - label post-change test failures as 'pre-existing' without running tests before the change
- ```bash
- grep -n "mock.module" <your-test-file>
- ```
- Must return no matches.
+ ## Delegation Template
- ## Step 4 — Run tests
+ When delegating a task affected by this skill, include:
- ```bash
- bun --smol test src/tools/__tests__/mutation-test.adversarial.test.ts --timeout 30000
```
-
- ## Step 5 — Check for helpers that bypass the seam
-
- Some test helpers (like `initGitRepo`) may use `require('node:child_process')` directly to bypass the mock. This is INTENTIONAL and CORRECT — they need the real subprocess to set up test fixtures.
-
- ```typescript
- function initGitRepo(dir: string): void {
- const { spawnSync: s } = require('node:child_process');
- s('git', ['init'], {
- cwd: dir,
- stdin: 'ignore', // AGENTS.md invariant 3: non-interactive
- timeout: 5000, // AGENTS.md invariant 3: bounded
- stdio: ['ignore', 'ignore', 'ignore'], // bounded output
- });
- }
+ SKILLS: file:.opencode/skills/generated/mock-to-internals-migration/SKILL.md
```
- Do NOT change these helpers to use the DI seam.
-
- ### Distinguishing safe local spies from module spies that need migration
-
- Not every `vi.spyOn` needs migration. Use this table to decide:
-
- | Spy target | Needs migration? | Why |
- |-----------|-----------------|-----|
- | `vi.spyOn(console, 'warn')` | NO | `console` is a global singleton, not a module export |
- | `vi.spyOn(process.stdout, 'write')` | NO | Global process object, not module-scoped |
- | `vi.spyOn(importedModule, 'functionName')` | YES | Module export — unreliable across shared test runner |
- | `vi.spyOn(someObject.method)` where object is from import | YES | Same as above |
-
- ## Common mistakes
-
- | Mistake | Why it fails |
- |---------|-------------|
- | Forgetting `mockReset()` in `afterEach` | `mockImplementation` state leaks between tests |
- | Using `type SpawnSyncFn` import from `node:child_process` | Type export doesn't exist at runtime; build fails |
- | Forgetting to restore `_internals.spawnSync` | Subsequent tests or other files get the mock |
- | Using async `fs.rm` in `afterEach` without `await` | Temp directories not cleaned up; use `rmSync` |
- | Forgetting vi.mock() captures closures at hoist-time | Reassigning mockFn.mockImplementation(newFn) in test body does NOT update the hoisted closure — mock still calls original function. Symptom: toHaveBeenCalledTimes(N) fails unexpectedly. Fix: use _internals seam instead |
- | Converting `initGitRepo`-style helpers | These need the REAL subprocess; keep them as `require()` |
- | Mock returning `Buffer` for `stdout` when implementation calls `.trim()` | `Buffer.prototype.trim` does not exist; throws TypeError at runtime. Match mock return type to implementation usage. |
-
- ## When NOT to add a function to _internals
+ ## Reviewer Checks
- Not every function belongs in `_internals`. Adding unnecessary functions creates mockable surface area that tests might accidentally override:
+ - grep for import-from-barrel patterns in test files and run affected tests after barrel changes
+ - grep for mock.module patterns that lack ...realModule spread
+ - grep for mock.module calls to confirm they spread real exports
+ - confirm bun:test isolation patterns match skill guidelines
+ - verify wrapCommand idempotency by calling it twice on the same command and confirming output equals single-call output
+ - Run `git diff <base>..<head> --name-only` and confirm file presence before marking introduced_by_pr: YES or NO
- - **Pure functions** (no side effects, no I/O) — test them directly with real inputs/outputs
- - **Constants and type guards** — these have no behavior to mock
- - **Functions only called from within the module itself** that don't touch external state
+ ## Test Engineer Checks
- Only add a function to `_internals` when:
- 1. It has side effects or external dependencies (filesystem, network, subprocess)
- 2. Tests need to control or observe those side effects
- 3. The function is called from production code that you need to test in isolation
+ - Add or update tests covering the trigger condition and the forbidden shortcut.
- ## Verification checklist
+ ## Source Knowledge IDs
- - [ ] Source file `_internals` includes the function
- - [ ] All calls in source use `_internals.fn(...)`
- - [ ] Test file has no `mock.module` calls
- - [ ] `beforeEach` saves original and assigns mock
- - [ ] `afterEach` restores original, calls `mockReset()`, cleans temp dir
- - [ ] Mock return type matches implementation usage (e.g., string stdout when `.trim()` is called)
- - [ ] All tests pass
- - [ ] ALL test files from Step 0b pass (not just the explicitly converted file)
- - [ ] `bun run build` passes
+ - 15870ee2-0813-406f-afd6-f34a05343584 — Pre-existing opt-in maps (memory, external_skills, skills) follow a consistent pattern — when implementing new gates, mirror the existing structure (separate SKILL_AGENT_TOOL_MAP, conditional merge in agents/index.ts) rather than inventing new mechanisms.</item>
+ - c3bbb12e-f8de-429d-9d3a-983e7929786c — When adding new exports to barrel files (e.g. src/tools/index.ts), verify all existing test files that import from that barrel still resolve correctly — new exports should not shadow or break existing import paths used by dependent tests.
+ - ece661ed-3626-4e1d-b95b-b6ab96a059e9 — Verify baseline code structure is sound before making modifications; an incomplete refactor split leaves structure broken and causes edits to fail validation
+ - 4d22c8a2-335b-46bd-91da-7968306c6107 — Partial Vitest→bun:test mock conversion causes runtime TypeErrors; when converting any test file, audit ALL mock.api usage to ensure complete migration — a single unconverted vi.fn() left next to a bun.mock() causes reset() to fail at runtime
+ - a152c109-c6f1-4338-8e18-1b98d2d358cf — Always spread real module exports into mock.module returns to prevent undeclared overrides from leaking across tests and causing intermittent failures
+ - 1baf97e1-b58e-4287-acdd-7b8c150b0e6b — Always load the writing-tests skill before modifying test files — SKILL_COMPLIANCE:none on a test-refactor task means the required skill was bypassed, increasing risk of mock isolation bugs and test pollution.
+ - 939330c8-be3d-4c11-842f-4a93433b7d1a — Coders must run lint alongside build before submitting — a clean build does not imply a clean lint, and lint failures force revision rounds that are entirely preventable.
+ - c66e7212-82ca-4b03-ad06-fece8199e401 — wrapCommand and similar command-transformation functions must be idempotent: detect and skip re-wrapping if the target executor has already applied its sandbox envelope (check for existing bwrap/sandbox-exec/Win32 wrapper patterns before wrapping again).
+ - 2041997b-c527-4cc0-bcf1-08f0cfd6777f — A test failure observed after your changes cannot be classified as 'pre-existing' without a pre-change baseline run — post-hoc attribution to pre-existing failures is unreliable and masks genuine regressions
+ - d07cf04b-05c2-4e80-b291-e2d0446d9d09 — When confirming a change in review, ensure the change is actually present and causally attributable to the PR; unverifiable attributions break the review gate.