@getmikk/core 2.0.11 → 2.0.13

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,176 @@
1
+ import * as fs from 'node:fs/promises'
2
+ import * as path from 'node:path'
3
+ import { randomUUID } from 'node:crypto'
4
+ import { writeFileAtomic } from './atomic-write.js'
5
+
6
+ export type ArtifactTransactionStatus = 'begin' | 'staged' | 'commit-ready'
7
+
8
+ export interface ArtifactWriteInput {
9
+ targetPath: string
10
+ content: string
11
+ encoding?: BufferEncoding
12
+ }
13
+
14
+ interface ArtifactWriteJournal {
15
+ targetPath: string
16
+ stagedPath: string
17
+ encoding: BufferEncoding
18
+ }
19
+
20
+ interface ArtifactTransactionJournal {
21
+ id: string
22
+ name: string
23
+ createdAt: string
24
+ status: ArtifactTransactionStatus
25
+ writes: ArtifactWriteJournal[]
26
+ committedAt?: string
27
+ }
28
+
29
+ export interface ArtifactTransactionOptions {
30
+ simulateCrashAt?: 'after-begin' | 'after-stage' | 'after-commit-marker'
31
+ }
32
+
33
+ export interface RecoverySummary {
34
+ recovered: number
35
+ rolledBack: number
36
+ removedJournals: number
37
+ }
38
+
39
+ function getTransactionDirectory(projectRoot: string): string {
40
+ return path.join(projectRoot, '.mikk', 'transactions')
41
+ }
42
+
43
+ async function writeJournal(journalPath: string, journal: ArtifactTransactionJournal): Promise<void> {
44
+ await writeFileAtomic(journalPath, JSON.stringify(journal, null, 2), { encoding: 'utf-8' })
45
+ }
46
+
47
+ function makeStagedPath(targetPath: string, id: string): string {
48
+ const dir = path.dirname(targetPath)
49
+ const base = path.basename(targetPath)
50
+ return path.join(dir, `.${base}.txn-${id}.staged`)
51
+ }
52
+
53
+ export async function runArtifactWriteTransaction(
54
+ projectRoot: string,
55
+ name: string,
56
+ writes: ArtifactWriteInput[],
57
+ options: ArtifactTransactionOptions = {},
58
+ ): Promise<void> {
59
+ if (writes.length === 0) return
60
+
61
+ const txDir = getTransactionDirectory(projectRoot)
62
+ await fs.mkdir(txDir, { recursive: true })
63
+
64
+ const id = `${Date.now()}-${randomUUID().slice(0, 8)}`
65
+ const journalPath = path.join(txDir, `${id}.journal.json`)
66
+ const journal: ArtifactTransactionJournal = {
67
+ id,
68
+ name,
69
+ createdAt: new Date().toISOString(),
70
+ status: 'begin',
71
+ writes: writes.map((w) => ({
72
+ targetPath: w.targetPath,
73
+ stagedPath: makeStagedPath(w.targetPath, id),
74
+ encoding: w.encoding ?? 'utf-8',
75
+ })),
76
+ }
77
+
78
+ await writeJournal(journalPath, journal)
79
+ if (options.simulateCrashAt === 'after-begin') {
80
+ throw new Error('Simulated crash after begin')
81
+ }
82
+
83
+ for (let i = 0; i < writes.length; i++) {
84
+ const input = writes[i]
85
+ const record = journal.writes[i]
86
+ await fs.mkdir(path.dirname(record.stagedPath), { recursive: true })
87
+ await writeFileAtomic(record.stagedPath, input.content, { encoding: record.encoding })
88
+ }
89
+
90
+ journal.status = 'staged'
91
+ await writeJournal(journalPath, journal)
92
+ if (options.simulateCrashAt === 'after-stage') {
93
+ throw new Error('Simulated crash after stage')
94
+ }
95
+
96
+ journal.status = 'commit-ready'
97
+ journal.committedAt = new Date().toISOString()
98
+ await writeJournal(journalPath, journal)
99
+ if (options.simulateCrashAt === 'after-commit-marker') {
100
+ throw new Error('Simulated crash after commit marker')
101
+ }
102
+
103
+ for (const write of journal.writes) {
104
+ await fs.rename(write.stagedPath, write.targetPath)
105
+ }
106
+
107
+ await fs.unlink(journalPath)
108
+ }
109
+
110
+ export async function recoverArtifactWriteTransactions(projectRoot: string): Promise<RecoverySummary> {
111
+ const txDir = getTransactionDirectory(projectRoot)
112
+ const summary: RecoverySummary = {
113
+ recovered: 0,
114
+ rolledBack: 0,
115
+ removedJournals: 0,
116
+ }
117
+
118
+ let entries: string[] = []
119
+ try {
120
+ entries = await fs.readdir(txDir)
121
+ } catch {
122
+ return summary
123
+ }
124
+
125
+ const journals = entries.filter((name) => name.endsWith('.journal.json'))
126
+
127
+ for (const file of journals) {
128
+ const journalPath = path.join(txDir, file)
129
+ let journal: ArtifactTransactionJournal | null = null
130
+ try {
131
+ const raw = await fs.readFile(journalPath, 'utf-8')
132
+ journal = JSON.parse(raw) as ArtifactTransactionJournal
133
+ } catch {
134
+ try {
135
+ await fs.unlink(journalPath)
136
+ summary.removedJournals += 1
137
+ } catch {
138
+ // Ignore broken journal cleanup failures.
139
+ }
140
+ continue
141
+ }
142
+
143
+ try {
144
+ if (journal.status === 'commit-ready') {
145
+ for (const write of journal.writes) {
146
+ try {
147
+ await fs.rename(write.stagedPath, write.targetPath)
148
+ } catch (err: any) {
149
+ if (err?.code !== 'ENOENT') {
150
+ throw err
151
+ }
152
+ }
153
+ }
154
+ summary.recovered += 1
155
+ } else {
156
+ for (const write of journal.writes) {
157
+ try {
158
+ await fs.unlink(write.stagedPath)
159
+ } catch {
160
+ // Ignore missing staged files.
161
+ }
162
+ }
163
+ summary.rolledBack += 1
164
+ }
165
+ } finally {
166
+ try {
167
+ await fs.unlink(journalPath)
168
+ summary.removedJournals += 1
169
+ } catch {
170
+ // Ignore missing journal file.
171
+ }
172
+ }
173
+ }
174
+
175
+ return summary
176
+ }
@@ -0,0 +1,131 @@
1
+ import * as fs from 'node:fs/promises'
2
+ import * as path from 'node:path'
3
+ import { randomUUID } from 'node:crypto'
4
+
5
+ export interface AtomicWriteOptions {
6
+ encoding?: BufferEncoding
7
+ mode?: number
8
+ lockTimeoutMs?: number
9
+ staleLockMs?: number
10
+ retryDelayMs?: number
11
+ }
12
+
13
+ const DEFAULT_LOCK_TIMEOUT_MS = 10_000
14
+ const DEFAULT_STALE_LOCK_MS = 60_000
15
+ const DEFAULT_RETRY_DELAY_MS = 50
16
+
17
+ const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
18
+
19
+ async function acquireFileLock(lockPath: string, options: AtomicWriteOptions): Promise<() => Promise<void>> {
20
+ const lockTimeoutMs = options.lockTimeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS
21
+ const staleLockMs = options.staleLockMs ?? DEFAULT_STALE_LOCK_MS
22
+ const retryDelayMs = options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS
23
+ const startedAt = Date.now()
24
+
25
+ while (true) {
26
+ try {
27
+ const fd = await fs.open(lockPath, 'wx')
28
+ try {
29
+ const payload = JSON.stringify({ pid: process.pid, acquiredAt: new Date().toISOString() })
30
+ await fd.writeFile(payload, { encoding: 'utf-8' })
31
+ await fd.sync()
32
+ } finally {
33
+ await fd.close()
34
+ }
35
+
36
+ return async () => {
37
+ try {
38
+ await fs.unlink(lockPath)
39
+ } catch {
40
+ // Ignore missing locks on release.
41
+ }
42
+ }
43
+ } catch (err: any) {
44
+ if (err?.code !== 'EEXIST') {
45
+ throw err
46
+ }
47
+
48
+ try {
49
+ const stat = await fs.stat(lockPath)
50
+ const ageMs = Date.now() - stat.mtimeMs
51
+ if (ageMs > staleLockMs) {
52
+ await fs.unlink(lockPath)
53
+ continue
54
+ }
55
+ } catch {
56
+ // Lock disappeared between stat/unlink checks — retry acquisition.
57
+ }
58
+
59
+ if (Date.now() - startedAt > lockTimeoutMs) {
60
+ throw new Error(`Timed out acquiring write lock for ${path.basename(lockPath)}`)
61
+ }
62
+
63
+ await sleep(retryDelayMs)
64
+ }
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Write a file atomically with a lock-file critical section.
70
+ *
71
+ * Guarantees:
72
+ * - Atomicity: write to temp file then rename
73
+ * - Isolation: one writer at a time via lock file
74
+ * - Durability: fsync temp file and parent directory (best effort)
75
+ */
76
+ export async function writeFileAtomic(
77
+ targetPath: string,
78
+ content: string,
79
+ options: AtomicWriteOptions = {}
80
+ ): Promise<void> {
81
+ const directory = path.dirname(targetPath)
82
+ const baseName = path.basename(targetPath)
83
+ const lockPath = `${targetPath}.lock`
84
+ const tempPath = path.join(
85
+ directory,
86
+ `.${baseName}.${process.pid}.${Date.now()}.${randomUUID().slice(0, 8)}.tmp`
87
+ )
88
+
89
+ await fs.mkdir(directory, { recursive: true })
90
+ const releaseLock = await acquireFileLock(lockPath, options)
91
+
92
+ try {
93
+ const fd = await fs.open(tempPath, 'w', options.mode)
94
+ try {
95
+ await fd.writeFile(content, { encoding: options.encoding ?? 'utf-8' })
96
+ await fd.sync()
97
+ } finally {
98
+ await fd.close()
99
+ }
100
+
101
+ await fs.rename(tempPath, targetPath)
102
+
103
+ // Best-effort directory fsync for rename durability.
104
+ try {
105
+ const dirFd = await fs.open(directory, 'r')
106
+ try {
107
+ await dirFd.sync()
108
+ } finally {
109
+ await dirFd.close()
110
+ }
111
+ } catch {
112
+ // Directory fsync can fail on some platforms/filesystems.
113
+ }
114
+ } finally {
115
+ try {
116
+ await fs.unlink(tempPath)
117
+ } catch {
118
+ // Temp file may already be moved/removed.
119
+ }
120
+ await releaseLock()
121
+ }
122
+ }
123
+
124
+ export async function writeJsonAtomic(
125
+ targetPath: string,
126
+ value: unknown,
127
+ options: AtomicWriteOptions = {}
128
+ ): Promise<void> {
129
+ const payload = JSON.stringify(value)
130
+ await writeFileAtomic(targetPath, payload, options)
131
+ }
package/src/utils/fs.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import * as fs from 'node:fs/promises'
2
2
  import * as path from 'node:path'
3
3
  import fg from 'fast-glob'
4
+ import { getDiscoveryExtensions } from './language-registry.js'
4
5
 
5
6
  // --- Well-known patterns for schema/config/route files ---------------------
6
7
  // These are structural files an AI agent needs but aren't source code.
@@ -275,7 +276,7 @@ function inferContextFileType(filePath: string): ContextFileType {
275
276
  }
276
277
 
277
278
  /** Recognised project language */
278
- export type ProjectLanguage = 'typescript' | 'javascript' | 'python' | 'go' | 'rust' | 'java' | 'ruby' | 'php' | 'csharp' | 'c' | 'cpp' | 'unknown'
279
+ export type ProjectLanguage = 'typescript' | 'javascript' | 'python' | 'go' | 'rust' | 'java' | 'swift' | 'ruby' | 'php' | 'csharp' | 'c' | 'cpp' | 'unknown'
279
280
 
280
281
  /** Auto-detect the project's primary language from manifest files */
281
282
  export async function detectProjectLanguage(projectRoot: string): Promise<ProjectLanguage> {
@@ -293,6 +294,7 @@ export async function detectProjectLanguage(projectRoot: string): Promise<Projec
293
294
  if (await exists('pyproject.toml') || await exists('setup.py') || await exists('requirements.txt')) return 'python'
294
295
  if (await exists('Gemfile')) return 'ruby'
295
296
  if (await exists('pom.xml') || await exists('build.gradle') || await exists('build.gradle.kts')) return 'java'
297
+ if (await exists('Package.swift')) return 'swift'
296
298
  if (await exists('composer.json')) return 'php'
297
299
  if (await hasGlob('*.csproj') || await hasGlob('*.sln')) return 'csharp'
298
300
  if (await hasGlob('CMakeLists.txt') || await hasGlob('**/*.cmake') || await hasGlob('*.cpp')) return 'cpp'
@@ -306,66 +308,76 @@ export function getDiscoveryPatterns(language: ProjectLanguage): { patterns: str
306
308
  const commonIgnore = [
307
309
  '**/.mikk/**', '**/.git/**', '**/coverage/**', '**/build/**',
308
310
  ]
311
+
312
+ const toPatterns = (lang: ProjectLanguage): string[] => {
313
+ return getDiscoveryExtensions(lang).map(ext => `**/*${ext}`)
314
+ }
315
+
309
316
  switch (language) {
310
317
  case 'typescript':
311
318
  return {
312
- patterns: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'],
319
+ patterns: toPatterns(language),
313
320
  ignore: [...commonIgnore, '**/node_modules/**', '**/dist/**', '**/.next/**', '**/.nuxt/**', '**/.svelte-kit/**', '**/*.d.ts', '**/*.test.{ts,js,tsx,jsx}', '**/*.spec.{ts,js,tsx,jsx}'],
314
321
  }
315
322
  case 'javascript':
316
323
  return {
317
- patterns: ['**/*.js', '**/*.jsx', '**/*.mjs', '**/*.cjs', '**/*.ts', '**/*.tsx'],
324
+ patterns: toPatterns(language),
318
325
  ignore: [...commonIgnore, '**/node_modules/**', '**/dist/**', '**/.next/**', '**/*.d.ts', '**/*.test.{ts,js,tsx,jsx}', '**/*.spec.{ts,js,tsx,jsx}'],
319
326
  }
320
327
  case 'python':
321
328
  return {
322
- patterns: ['**/*.py'],
329
+ patterns: toPatterns(language),
323
330
  ignore: [...commonIgnore, '**/__pycache__/**', '**/venv/**', '**/.venv/**', '**/.tox/**', '**/test_*.py', '**/*_test.py'],
324
331
  }
325
332
  case 'go':
326
333
  return {
327
- patterns: ['**/*.go'],
334
+ patterns: toPatterns(language),
328
335
  ignore: [...commonIgnore, '**/vendor/**', '**/*_test.go'],
329
336
  }
330
337
  case 'rust':
331
338
  return {
332
- patterns: ['**/*.rs'],
339
+ patterns: toPatterns(language),
333
340
  ignore: [...commonIgnore, '**/target/**'],
334
341
  }
335
342
  case 'java':
336
343
  return {
337
- patterns: ['**/*.java', '**/*.kt'],
344
+ patterns: toPatterns(language),
338
345
  ignore: [...commonIgnore, '**/target/**', '**/.gradle/**', '**/Test*.java', '**/*Test.java'],
339
346
  }
347
+ case 'swift':
348
+ return {
349
+ patterns: toPatterns(language),
350
+ ignore: [...commonIgnore, '**/.build/**', '**/Tests/**'],
351
+ }
340
352
  case 'ruby':
341
353
  return {
342
- patterns: ['**/*.rb'],
354
+ patterns: toPatterns(language),
343
355
  ignore: [...commonIgnore, '**/vendor/**', '**/*_spec.rb', '**/spec/**'],
344
356
  }
345
357
  case 'php':
346
358
  return {
347
- patterns: ['**/*.php'],
359
+ patterns: toPatterns(language),
348
360
  ignore: [...commonIgnore, '**/vendor/**', '**/*Test.php'],
349
361
  }
350
362
  case 'csharp':
351
363
  return {
352
- patterns: ['**/*.cs'],
364
+ patterns: toPatterns(language),
353
365
  ignore: [...commonIgnore, '**/bin/**', '**/obj/**'],
354
366
  }
355
367
  case 'cpp':
356
368
  return {
357
- patterns: ['**/*.cpp', '**/*.cc', '**/*.cxx', '**/*.hpp', '**/*.hxx', '**/*.h'],
369
+ patterns: toPatterns(language),
358
370
  ignore: [...commonIgnore, '**/build/**', '**/cmake-build-*/**'],
359
371
  }
360
372
  case 'c':
361
373
  return {
362
- patterns: ['**/*.c', '**/*.h'],
374
+ patterns: toPatterns(language),
363
375
  ignore: [...commonIgnore, '**/build/**'],
364
376
  }
365
377
  default:
366
378
  // Fallback: discover JS/TS (most common)
367
379
  return {
368
- patterns: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'],
380
+ patterns: toPatterns(language),
369
381
  ignore: [...commonIgnore, '**/node_modules/**', '**/dist/**', '**/*.d.ts'],
370
382
  }
371
383
  }
@@ -550,6 +562,14 @@ const LANGUAGE_IGNORE_TEMPLATES: Record<ProjectLanguage, string[]> = {
550
562
  'gradle/',
551
563
  '',
552
564
  ],
565
+ swift: [
566
+ '# Swift artifacts',
567
+ '.build/',
568
+ '.swiftpm/',
569
+ 'Packages/',
570
+ 'Tests/',
571
+ '',
572
+ ],
553
573
  ruby: [
554
574
  '# Test files',
555
575
  '*_spec.rb',
@@ -0,0 +1,82 @@
1
+ export type ParserKind = 'oxc' | 'go' | 'tree-sitter' | 'unknown'
2
+
3
+ export type RegistryLanguage =
4
+ | 'typescript'
5
+ | 'javascript'
6
+ | 'python'
7
+ | 'go'
8
+ | 'rust'
9
+ | 'java'
10
+ | 'kotlin'
11
+ | 'swift'
12
+ | 'ruby'
13
+ | 'php'
14
+ | 'csharp'
15
+ | 'c'
16
+ | 'cpp'
17
+ | 'unknown'
18
+
19
+ const OXC_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'] as const
20
+ const GO_EXTENSIONS = ['.go'] as const
21
+ const TREE_SITTER_EXTENSIONS = [
22
+ '.py', '.java', '.kt', '.kts', '.swift',
23
+ '.c', '.h', '.cpp', '.cc', '.cxx', '.hpp', '.hxx', '.hh',
24
+ '.cs', '.rs', '.php', '.rb',
25
+ ] as const
26
+
27
+ const PARSER_EXTENSIONS: Record<Exclude<ParserKind, 'unknown'>, readonly string[]> = {
28
+ oxc: OXC_EXTENSIONS,
29
+ go: GO_EXTENSIONS,
30
+ 'tree-sitter': TREE_SITTER_EXTENSIONS,
31
+ }
32
+
33
+ const LANGUAGE_EXTENSIONS: Record<RegistryLanguage, readonly string[]> = {
34
+ typescript: ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'],
35
+ javascript: ['.js', '.jsx', '.mjs', '.cjs', '.ts', '.tsx'],
36
+ python: ['.py'],
37
+ go: ['.go'],
38
+ rust: ['.rs'],
39
+ kotlin: ['.kt', '.kts'],
40
+ java: ['.java', '.kt', '.kts'],
41
+ swift: ['.swift'],
42
+ ruby: ['.rb'],
43
+ php: ['.php'],
44
+ csharp: ['.cs'],
45
+ c: ['.c', '.h'],
46
+ cpp: ['.cpp', '.cc', '.cxx', '.hpp', '.hxx', '.hh', '.h'],
47
+ unknown: ['.ts', '.tsx', '.js', '.jsx'],
48
+ }
49
+
50
+ const EXT_TO_PARSER = new Map<string, ParserKind>()
51
+ for (const ext of OXC_EXTENSIONS) EXT_TO_PARSER.set(ext, 'oxc')
52
+ for (const ext of GO_EXTENSIONS) EXT_TO_PARSER.set(ext, 'go')
53
+ for (const ext of TREE_SITTER_EXTENSIONS) EXT_TO_PARSER.set(ext, 'tree-sitter')
54
+
55
+ const EXT_TO_LANGUAGE = new Map<string, RegistryLanguage>()
56
+ for (const [language, extensions] of Object.entries(LANGUAGE_EXTENSIONS)) {
57
+ for (const ext of extensions) {
58
+ if (!EXT_TO_LANGUAGE.has(ext)) {
59
+ EXT_TO_LANGUAGE.set(ext, language as RegistryLanguage)
60
+ }
61
+ }
62
+ }
63
+
64
+ export function parserKindForExtension(ext: string): ParserKind {
65
+ return EXT_TO_PARSER.get(ext.toLowerCase()) ?? 'unknown'
66
+ }
67
+
68
+ export function languageForExtension(ext: string): RegistryLanguage {
69
+ return EXT_TO_LANGUAGE.get(ext.toLowerCase()) ?? 'unknown'
70
+ }
71
+
72
+ export function getParserExtensions(kind: Exclude<ParserKind, 'unknown'>): readonly string[] {
73
+ return PARSER_EXTENSIONS[kind]
74
+ }
75
+
76
+ export function getDiscoveryExtensions(language: RegistryLanguage): readonly string[] {
77
+ return LANGUAGE_EXTENSIONS[language]
78
+ }
79
+
80
+ export function isTreeSitterExtension(ext: string): boolean {
81
+ return parserKindForExtension(ext) === 'tree-sitter'
82
+ }
@@ -94,4 +94,10 @@ describe('AdrManager', () => {
94
94
  const decisions = await manager.list()
95
95
  expect(decisions).toHaveLength(0)
96
96
  })
97
+
98
+ it('returns false when removing a missing decision', async () => {
99
+ const manager = new AdrManager(CONTRACT_PATH)
100
+ const success = await manager.remove('ADR-404')
101
+ expect(success).toBe(false)
102
+ })
97
103
  })
@@ -0,0 +1,73 @@
1
+ import { describe, it, expect } from 'bun:test'
2
+ import * as fs from 'node:fs/promises'
3
+ import * as path from 'node:path'
4
+ import * as os from 'node:os'
5
+ import {
6
+ runArtifactWriteTransaction,
7
+ recoverArtifactWriteTransactions,
8
+ } from '../src/utils/artifact-transaction'
9
+
10
+ describe('artifact write transactions', () => {
11
+ it('commits grouped writes atomically', async () => {
12
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'mikk-tx-'))
13
+ const aPath = path.join(root, 'mikk.lock.json')
14
+ const bPath = path.join(root, 'claude.md')
15
+
16
+ await runArtifactWriteTransaction(root, 'commit-test', [
17
+ { targetPath: aPath, content: '{"ok":true}' },
18
+ { targetPath: bPath, content: '# context' },
19
+ ])
20
+
21
+ expect(await fs.readFile(aPath, 'utf-8')).toContain('ok')
22
+ expect(await fs.readFile(bPath, 'utf-8')).toContain('context')
23
+
24
+ await fs.rm(root, { recursive: true, force: true })
25
+ })
26
+
27
+ it('rolls back staged writes after pre-commit crash', async () => {
28
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'mikk-tx-'))
29
+ const lockPath = path.join(root, 'mikk.lock.json')
30
+
31
+ await expect(
32
+ runArtifactWriteTransaction(
33
+ root,
34
+ 'rollback-test',
35
+ [{ targetPath: lockPath, content: '{"v":1}' }],
36
+ { simulateCrashAt: 'after-stage' },
37
+ ),
38
+ ).rejects.toThrow('Simulated crash after stage')
39
+
40
+ const summary = await recoverArtifactWriteTransactions(root)
41
+ expect(summary.rolledBack).toBeGreaterThanOrEqual(1)
42
+
43
+ let exists = true
44
+ try {
45
+ await fs.access(lockPath)
46
+ } catch {
47
+ exists = false
48
+ }
49
+ expect(exists).toBe(false)
50
+
51
+ await fs.rm(root, { recursive: true, force: true })
52
+ })
53
+
54
+ it('recovers commit-ready journals after post-commit-marker crash', async () => {
55
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'mikk-tx-'))
56
+ const lockPath = path.join(root, 'mikk.lock.json')
57
+
58
+ await expect(
59
+ runArtifactWriteTransaction(
60
+ root,
61
+ 'recovery-test',
62
+ [{ targetPath: lockPath, content: '{"v":2}' }],
63
+ { simulateCrashAt: 'after-commit-marker' },
64
+ ),
65
+ ).rejects.toThrow('Simulated crash after commit marker')
66
+
67
+ const summary = await recoverArtifactWriteTransactions(root)
68
+ expect(summary.recovered).toBeGreaterThanOrEqual(1)
69
+ expect(await fs.readFile(lockPath, 'utf-8')).toContain('"v":2')
70
+
71
+ await fs.rm(root, { recursive: true, force: true })
72
+ })
73
+ })
@@ -49,6 +49,18 @@ describe('MikkContractSchema', () => {
49
49
  expect(result.data.overwrite.mode).toBe('never')
50
50
  }
51
51
  })
52
+
53
+ it('rejects contract with invalid declared.modules type', () => {
54
+ const bad = {
55
+ ...validContract,
56
+ declared: {
57
+ ...validContract.declared,
58
+ modules: 'not-an-array',
59
+ },
60
+ }
61
+ const result = MikkContractSchema.safeParse(bad)
62
+ expect(result.success).toBe(false)
63
+ })
52
64
  })
53
65
 
54
66
  describe('LockCompiler', () => {
@@ -131,4 +131,16 @@ describe('DeadCodeDetector', () => {
131
131
 
132
132
  expect(result.deadFunctions).toHaveLength(0) // InternalHelper is called by exported fn in same file
133
133
  })
134
+
135
+ it('keeps deadCount aligned with deadFunctions length', () => {
136
+ const graph = buildTestGraph([
137
+ ['A', 'nothing'],
138
+ ['B', 'nothing'],
139
+ ])
140
+ const lock = generateDummyLock(graph.nodes)
141
+
142
+ const detector = new DeadCodeDetector(graph, lock)
143
+ const result = detector.detect()
144
+ expect(result.deadCount).toBe(result.deadFunctions.length)
145
+ })
134
146
  })
@@ -73,4 +73,10 @@ describe('OxcResolver - ESM and CJS Resolution', () => {
73
73
  const res = await resolver.resolve('./local', path.join(FIXTURE_DIR, 'index.ts'))
74
74
  expect(res).toContain('.test-fixture-esm/local.ts')
75
75
  })
76
+
77
+ it('does not throw for missing package imports', async () => {
78
+ const resolver = new OxcResolver(FIXTURE_DIR)
79
+ const res = await resolver.resolve('totally-missing-pkg', path.join(FIXTURE_DIR, 'index.ts'))
80
+ expect(typeof res).toBe('string')
81
+ })
76
82
  })