@getmikk/core 2.0.12 → 2.0.14

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.
Files changed (44) hide show
  1. package/README.md +12 -3
  2. package/package.json +1 -1
  3. package/src/analysis/index.ts +9 -0
  4. package/src/analysis/taint-analysis.ts +419 -0
  5. package/src/analysis/type-flow.ts +247 -0
  6. package/src/cache/incremental-cache.ts +272 -0
  7. package/src/cache/index.ts +1 -0
  8. package/src/contract/adr-manager.ts +5 -4
  9. package/src/contract/contract-generator.ts +31 -3
  10. package/src/contract/contract-writer.ts +3 -2
  11. package/src/contract/lock-compiler.ts +34 -0
  12. package/src/contract/lock-reader.ts +62 -5
  13. package/src/contract/schema.ts +10 -0
  14. package/src/index.ts +14 -1
  15. package/src/parser/error-recovery.ts +646 -0
  16. package/src/parser/index.ts +330 -74
  17. package/src/parser/oxc-parser.ts +3 -2
  18. package/src/parser/tree-sitter/parser.ts +59 -9
  19. package/src/parser/tree-sitter/queries.ts +27 -0
  20. package/src/parser/types.ts +1 -1
  21. package/src/security/index.ts +1 -0
  22. package/src/security/scanner.ts +342 -0
  23. package/src/utils/artifact-transaction.ts +176 -0
  24. package/src/utils/atomic-write.ts +131 -0
  25. package/src/utils/fs.ts +76 -25
  26. package/src/utils/language-registry.ts +95 -0
  27. package/src/utils/minimatch.ts +49 -6
  28. package/tests/adr-manager.test.ts +6 -0
  29. package/tests/artifact-transaction.test.ts +73 -0
  30. package/tests/contract.test.ts +12 -0
  31. package/tests/dead-code.test.ts +12 -0
  32. package/tests/esm-resolver.test.ts +6 -0
  33. package/tests/fs.test.ts +22 -1
  34. package/tests/fuzzy-match.test.ts +6 -0
  35. package/tests/go-parser.test.ts +7 -0
  36. package/tests/graph.test.ts +10 -0
  37. package/tests/hash.test.ts +6 -0
  38. package/tests/impact-classified.test.ts +13 -0
  39. package/tests/js-parser.test.ts +10 -0
  40. package/tests/language-registry.test.ts +64 -0
  41. package/tests/parse-diagnostics.test.ts +115 -0
  42. package/tests/parser.test.ts +36 -0
  43. package/tests/tree-sitter-parser.test.ts +201 -0
  44. package/tests/ts-parser.test.ts +6 -0
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' | 'polyglot'
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,68 +308,95 @@ 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
+ if (lang === 'polyglot') {
314
+ // For polyglot, use LANGUAGE_EXTENSIONS.polyglot directly
315
+ return getDiscoveryExtensions('polyglot' as any).map(ext => `**/*${ext}`)
316
+ }
317
+ return getDiscoveryExtensions(lang as any).map(ext => `**/*${ext}`)
318
+ }
319
+
309
320
  switch (language) {
310
321
  case 'typescript':
311
322
  return {
312
- patterns: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'],
313
- ignore: [...commonIgnore, '**/node_modules/**', '**/dist/**', '**/.next/**', '**/.nuxt/**', '**/.svelte-kit/**', '**/*.d.ts', '**/*.test.{ts,js,tsx,jsx}', '**/*.spec.{ts,js,tsx,jsx}'],
323
+ patterns: toPatterns(language),
324
+ ignore: [...commonIgnore, '**/node_modules/**', '**/dist/**', '**/.next/**', '**/.nuxt/**', '**/.svelte-kit/**', '**/*.d.ts', '**/*.test.{ts,js,tsx,jsx}', '**/*.spec.{ts,js,tsx,jsx}', '**/venv/**', '**/.venv/**'],
314
325
  }
315
326
  case 'javascript':
316
327
  return {
317
- patterns: ['**/*.js', '**/*.jsx', '**/*.mjs', '**/*.cjs', '**/*.ts', '**/*.tsx'],
318
- ignore: [...commonIgnore, '**/node_modules/**', '**/dist/**', '**/.next/**', '**/*.d.ts', '**/*.test.{ts,js,tsx,jsx}', '**/*.spec.{ts,js,tsx,jsx}'],
328
+ patterns: toPatterns(language),
329
+ ignore: [...commonIgnore, '**/node_modules/**', '**/dist/**', '**/.next/**', '**/*.d.ts', '**/*.test.{ts,js,tsx,jsx}', '**/*.spec.{ts,js,tsx,jsx}', '**/venv/**', '**/.venv/**'],
319
330
  }
320
331
  case 'python':
321
332
  return {
322
- patterns: ['**/*.py'],
323
- ignore: [...commonIgnore, '**/__pycache__/**', '**/venv/**', '**/.venv/**', '**/.tox/**', '**/test_*.py', '**/*_test.py'],
333
+ patterns: toPatterns(language),
334
+ ignore: [...commonIgnore, '**/__pycache__/**', '**/venv/**', '**/.venv/**', '**/.tox/**', '**/test_*.py', '**/*_test.py', '**/lib/site-packages/**'],
324
335
  }
325
336
  case 'go':
326
337
  return {
327
- patterns: ['**/*.go'],
338
+ patterns: toPatterns(language),
328
339
  ignore: [...commonIgnore, '**/vendor/**', '**/*_test.go'],
329
340
  }
330
341
  case 'rust':
331
342
  return {
332
- patterns: ['**/*.rs'],
343
+ patterns: toPatterns(language),
333
344
  ignore: [...commonIgnore, '**/target/**'],
334
345
  }
335
346
  case 'java':
336
347
  return {
337
- patterns: ['**/*.java', '**/*.kt'],
348
+ patterns: toPatterns(language),
338
349
  ignore: [...commonIgnore, '**/target/**', '**/.gradle/**', '**/Test*.java', '**/*Test.java'],
339
350
  }
351
+ case 'swift':
352
+ return {
353
+ patterns: toPatterns(language),
354
+ ignore: [...commonIgnore, '**/.build/**', '**/Tests/**'],
355
+ }
340
356
  case 'ruby':
341
357
  return {
342
- patterns: ['**/*.rb'],
343
- ignore: [...commonIgnore, '**/vendor/**', '**/*_spec.rb', '**/spec/**'],
358
+ patterns: toPatterns(language),
359
+ ignore: [...commonIgnore, '**/vendor/**', '**/*.gemspec'],
344
360
  }
345
361
  case 'php':
346
362
  return {
347
- patterns: ['**/*.php'],
348
- ignore: [...commonIgnore, '**/vendor/**', '**/*Test.php'],
363
+ patterns: toPatterns(language),
364
+ ignore: [...commonIgnore, '**/vendor/**', '**/tests/**', '**/Test*.php'],
349
365
  }
350
366
  case 'csharp':
351
367
  return {
352
- patterns: ['**/*.cs'],
353
- ignore: [...commonIgnore, '**/bin/**', '**/obj/**'],
368
+ patterns: toPatterns(language),
369
+ ignore: [...commonIgnore, '**/bin/**', '**/obj/**', '**/*Test.cs'],
370
+ }
371
+ case 'c':
372
+ return {
373
+ patterns: toPatterns(language),
374
+ ignore: [...commonIgnore, '**/*.h'],
354
375
  }
355
376
  case 'cpp':
356
377
  return {
357
- patterns: ['**/*.cpp', '**/*.cc', '**/*.cxx', '**/*.hpp', '**/*.hxx', '**/*.h'],
358
- ignore: [...commonIgnore, '**/build/**', '**/cmake-build-*/**'],
378
+ patterns: toPatterns(language),
379
+ ignore: [...commonIgnore, '**/build/**', '**/*.hpp'],
359
380
  }
360
- case 'c':
381
+ case 'polyglot':
361
382
  return {
362
- patterns: ['**/*.c', '**/*.h'],
363
- ignore: [...commonIgnore, '**/build/**'],
383
+ patterns: toPatterns(language),
384
+ ignore: [
385
+ ...commonIgnore,
386
+ '**/node_modules/**', '**/dist/**', '**/.next/**', '**/.nuxt/**', '**/.svelte-kit/**',
387
+ '**/__pycache__/**', '**/venv/**', '**/.venv/**', '**/.tox/**', '**/lib/site-packages/**',
388
+ '**/vendor/**', '**/target/**', '**/.gradle/**', '**/.build/**', '**/bin/**', '**/obj/**',
389
+ '**/*.d.ts', '**/*.test.{ts,js,tsx,jsx}', '**/*.spec.{ts,js,tsx,jsx}',
390
+ '**/test_*.py', '**/*_test.py', '**/Test*.java', '**/*Test.java', '**/*Test.cs',
391
+ ],
364
392
  }
365
- default:
366
- // Fallback: discover JS/TS (most common)
393
+ case 'unknown':
367
394
  return {
368
- patterns: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'],
369
- ignore: [...commonIgnore, '**/node_modules/**', '**/dist/**', '**/*.d.ts'],
395
+ patterns: ['**/*.{ts,tsx,js,jsx}'],
396
+ ignore: [...commonIgnore, '**/node_modules/**'],
370
397
  }
398
+ default:
399
+ return { patterns: [], ignore: commonIgnore }
371
400
  }
372
401
  }
373
402
 
@@ -550,6 +579,14 @@ const LANGUAGE_IGNORE_TEMPLATES: Record<ProjectLanguage, string[]> = {
550
579
  'gradle/',
551
580
  '',
552
581
  ],
582
+ swift: [
583
+ '# Swift artifacts',
584
+ '.build/',
585
+ '.swiftpm/',
586
+ 'Packages/',
587
+ 'Tests/',
588
+ '',
589
+ ],
553
590
  ruby: [
554
591
  '# Test files',
555
592
  '*_spec.rb',
@@ -605,6 +642,20 @@ const LANGUAGE_IGNORE_TEMPLATES: Record<ProjectLanguage, string[]> = {
605
642
  '__tests__/',
606
643
  '',
607
644
  ],
645
+ polyglot: [
646
+ '# Multi-language project',
647
+ '**/node_modules/**',
648
+ '**/venv/**',
649
+ '**/.venv/**',
650
+ '**/__pycache__/**',
651
+ '**/site-packages/**',
652
+ '**/vendor/**',
653
+ '**/target/**',
654
+ '**/build/**',
655
+ '**/dist/**',
656
+ '**/.next/**',
657
+ '',
658
+ ],
608
659
  }
609
660
 
610
661
  /**
@@ -0,0 +1,95 @@
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
+ | 'polyglot'
18
+ | 'unknown'
19
+
20
+ const OXC_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'] as const
21
+ const GO_EXTENSIONS = ['.go'] as const
22
+ const TREE_SITTER_EXTENSIONS = [
23
+ '.py', '.java', '.kt', '.kts', '.swift',
24
+ '.c', '.h', '.cpp', '.cc', '.cxx', '.hpp', '.hxx', '.hh',
25
+ '.cs', '.rs', '.php', '.rb',
26
+ ] as const
27
+
28
+ const PARSER_EXTENSIONS: Record<Exclude<ParserKind, 'unknown'>, readonly string[]> = {
29
+ oxc: OXC_EXTENSIONS,
30
+ go: GO_EXTENSIONS,
31
+ 'tree-sitter': TREE_SITTER_EXTENSIONS,
32
+ }
33
+
34
+ const LANGUAGE_EXTENSIONS: Record<RegistryLanguage, readonly string[]> = {
35
+ typescript: ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'],
36
+ javascript: ['.js', '.jsx', '.mjs', '.cjs', '.ts', '.tsx'],
37
+ python: ['.py'],
38
+ go: ['.go'],
39
+ rust: ['.rs'],
40
+ kotlin: ['.kt', '.kts'],
41
+ java: ['.java', '.kt', '.kts'],
42
+ swift: ['.swift'],
43
+ ruby: ['.rb'],
44
+ php: ['.php'],
45
+ csharp: ['.cs'],
46
+ c: ['.c', '.h'],
47
+ cpp: ['.cpp', '.cc', '.cxx', '.hpp', '.hxx', '.hh', '.h'],
48
+ polyglot: [
49
+ '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs',
50
+ '.py',
51
+ '.go',
52
+ '.rs',
53
+ '.java', '.kt', '.kts',
54
+ '.swift',
55
+ '.rb',
56
+ '.php',
57
+ '.cs',
58
+ '.c', '.h', '.cpp', '.cc', '.cxx', '.hpp', '.hxx', '.hh',
59
+ ],
60
+ unknown: ['.ts', '.tsx', '.js', '.jsx'],
61
+ }
62
+
63
+ const EXT_TO_PARSER = new Map<string, ParserKind>()
64
+ for (const ext of OXC_EXTENSIONS) EXT_TO_PARSER.set(ext, 'oxc')
65
+ for (const ext of GO_EXTENSIONS) EXT_TO_PARSER.set(ext, 'go')
66
+ for (const ext of TREE_SITTER_EXTENSIONS) EXT_TO_PARSER.set(ext, 'tree-sitter')
67
+
68
+ const EXT_TO_LANGUAGE = new Map<string, RegistryLanguage>()
69
+ for (const [language, extensions] of Object.entries(LANGUAGE_EXTENSIONS)) {
70
+ for (const ext of extensions) {
71
+ if (!EXT_TO_LANGUAGE.has(ext)) {
72
+ EXT_TO_LANGUAGE.set(ext, language as RegistryLanguage)
73
+ }
74
+ }
75
+ }
76
+
77
+ export function parserKindForExtension(ext: string): ParserKind {
78
+ return EXT_TO_PARSER.get(ext.toLowerCase()) ?? 'unknown'
79
+ }
80
+
81
+ export function languageForExtension(ext: string): RegistryLanguage {
82
+ return EXT_TO_LANGUAGE.get(ext.toLowerCase()) ?? 'unknown'
83
+ }
84
+
85
+ export function getParserExtensions(kind: Exclude<ParserKind, 'unknown'>): readonly string[] {
86
+ return PARSER_EXTENSIONS[kind]
87
+ }
88
+
89
+ export function getDiscoveryExtensions(language: RegistryLanguage): readonly string[] {
90
+ return LANGUAGE_EXTENSIONS[language]
91
+ }
92
+
93
+ export function isTreeSitterExtension(ext: string): boolean {
94
+ return parserKindForExtension(ext) === 'tree-sitter'
95
+ }
@@ -4,7 +4,7 @@
4
4
  * Rules:
5
5
  * - Pattern with no glob chars (*, ?, {, [) → directory prefix match
6
6
  * "src/auth" matches "src/auth/jwt.ts" and "src/auth" itself
7
- * - "**" matches any depth
7
+ * - "**" matches any depth (zero or more directory segments)
8
8
  * - "*" matches within a single directory segment
9
9
  */
10
10
  export function minimatch(filePath: string, pattern: string): boolean {
@@ -17,12 +17,55 @@ export function minimatch(filePath: string, pattern: string): boolean {
17
17
  return normalizedPath === bare || normalizedPath.startsWith(bare + '/')
18
18
  }
19
19
 
20
- // Convert glob to regex
21
- const regexStr = normalizedPattern
20
+ // Handle patterns that start with ** - these should match anywhere in the path
21
+ // e.g., **/venv/** should match if there's a /venv/ segment anywhere
22
+ if (normalizedPattern.startsWith('**/')) {
23
+ const rest = normalizedPattern.slice(3) // Remove **/
24
+ // Check if the rest of the pattern appears as a path segment
25
+ // For **/venv/**, check if /venv/ is in the path
26
+ // For **/node_modules/**, check if /node_modules/ is in the path
27
+ const segments = normalizedPath.split('/')
28
+ const patternSegments = rest.split('/').filter(Boolean)
29
+
30
+ // Check if pattern segments appear consecutively in path
31
+ for (let i = 0; i <= segments.length - patternSegments.length; i++) {
32
+ let match = true
33
+ for (let j = 0; j < patternSegments.length; j++) {
34
+ const pseg = patternSegments[j].replace(/\*/g, '[^/]*')
35
+ if (!new RegExp('^' + pseg + '$', 'i').test(segments[i + j])) {
36
+ match = false
37
+ break
38
+ }
39
+ }
40
+ if (match) return true
41
+ }
42
+ return false
43
+ }
44
+
45
+ // Convert glob to regex (for patterns not starting with **)
46
+ let regexStr = normalizedPattern
47
+ .replace(/\[/g, '\\[')
48
+ .replace(/\]/g, '\\]')
22
49
  .replace(/\./g, '\\.')
23
- .replace(/\*\*\//g, '(?:.+/)?')
24
- .replace(/\*\*/g, '.*')
25
- .replace(/\*/g, '[^/]*')
50
+
51
+ // Replace **/ at end with (?:[^/]+/)* - matches zero or more dir segments ending with /
52
+ // But we need to handle path/** specifically - matching path/file, path/dir/file, etc.
53
+
54
+ // Handle trailing /** specifically - should match path itself and anything under it
55
+ if (normalizedPattern.endsWith('/**')) {
56
+ const base = normalizedPattern.slice(0, -3) // Remove /**
57
+ // Match either exact base or base + anything
58
+ return normalizedPath === base || normalizedPath.startsWith(base + '/')
59
+ }
60
+
61
+ // Replace **/ with (?:[^/]+/)* - matches zero or more directory segments
62
+ regexStr = regexStr.replace(/\*\*\//g, '(?:[^/]+/)*')
63
+ // Replace trailing ** with (?:[^/]+/)*[^/]+ - matches zero or more at end
64
+ regexStr = regexStr.replace(/\*\*$/g, '(?:[^/]+/)*[^/]+')
65
+ // Standalone **
66
+ regexStr = regexStr.replace(/\*\*/g, '(?:[^/]+/)*[^/]+')
67
+ // Single * matches any characters except slash
68
+ regexStr = regexStr.replace(/\*/g, '[^/]*')
26
69
 
27
70
  return new RegExp(`^${regexStr}$`, 'i').test(normalizedPath)
28
71
  }
@@ -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
  })
package/tests/fs.test.ts CHANGED
@@ -75,6 +75,12 @@ describe('detectProjectLanguage', () => {
75
75
  })
76
76
  })
77
77
 
78
+ it('detects Swift from Package.swift', async () => {
79
+ await withFile('Package.swift', async () => {
80
+ expect(await detectProjectLanguage(tmpDir)).toBe('swift')
81
+ })
82
+ })
83
+
78
84
  it('detects C# from .csproj file', async () => {
79
85
  await withFile('MyApp.csproj', async () => {
80
86
  expect(await detectProjectLanguage(tmpDir)).toBe('csharp')
@@ -116,7 +122,7 @@ describe('detectProjectLanguage', () => {
116
122
  describe('getDiscoveryPatterns', () => {
117
123
  const languages: ProjectLanguage[] = [
118
124
  'typescript', 'javascript', 'python', 'go', 'rust',
119
- 'java', 'ruby', 'php', 'csharp', 'unknown',
125
+ 'java', 'swift', 'ruby', 'php', 'csharp', 'unknown',
120
126
  ]
121
127
 
122
128
  for (const lang of languages) {
@@ -143,6 +149,21 @@ describe('getDiscoveryPatterns', () => {
143
149
  expect(patterns).toContain('**/*.py')
144
150
  })
145
151
 
152
+ it('java discovery patterns include Kotlin scripts for mixed JVM codebases', () => {
153
+ const { patterns } = getDiscoveryPatterns('java')
154
+ expect(patterns).toContain('**/*.kts')
155
+ })
156
+
157
+ it('swift patterns include .swift', () => {
158
+ const { patterns } = getDiscoveryPatterns('swift')
159
+ expect(patterns).toContain('**/*.swift')
160
+ })
161
+
162
+ it('cpp patterns include .hh headers', () => {
163
+ const { patterns } = getDiscoveryPatterns('cpp')
164
+ expect(patterns).toContain('**/*.hh')
165
+ })
166
+
146
167
  it('all languages ignore .mikk and .git', () => {
147
168
  for (const lang of languages) {
148
169
  const { ignore } = getDiscoveryPatterns(lang)
@@ -61,6 +61,12 @@ describe('levenshtein', () => {
61
61
  test('insertion', () => {
62
62
  expect(levenshtein('test', 'tests')).toBe(1)
63
63
  })
64
+
65
+ test('distance is symmetric', () => {
66
+ expect(levenshtein('verifyToken', 'tokenVerify')).toBe(
67
+ levenshtein('tokenVerify', 'verifyToken'),
68
+ )
69
+ })
64
70
  })
65
71
 
66
72
  describe('splitCamelCase', () => {
@@ -363,4 +363,11 @@ describe('GoParser', () => {
363
363
  const resolved = await parser.resolveImports(files, '/tmp/no-gomod-' + Date.now())
364
364
  expect(resolved.length).toBe(1)
365
365
  })
366
+
367
+ test('parse keeps deterministic hash for identical input', async () => {
368
+ const parser = new GoParser()
369
+ const a = await parser.parse('auth/service.go', SIMPLE_GO)
370
+ const b = await parser.parse('auth/service.go', SIMPLE_GO)
371
+ expect(a.hash).toBe(b.hash)
372
+ })
366
373
  })
@@ -167,4 +167,14 @@ describe('ClusterDetector', () => {
167
167
  expect(score).toBeGreaterThanOrEqual(0)
168
168
  expect(score).toBeLessThanOrEqual(1)
169
169
  })
170
+
171
+ it('keeps distinct function nodes for same function names in different files', () => {
172
+ const files = [
173
+ mockParsedFile('src/auth/a.ts', [mockFunction('shared', [], 'src/auth/a.ts')]),
174
+ mockParsedFile('src/db/b.ts', [mockFunction('shared', [], 'src/db/b.ts')]),
175
+ ]
176
+ const graph = new GraphBuilder().build(files)
177
+ expect(graph.nodes.has('fn:src/auth/a.ts:shared')).toBe(true)
178
+ expect(graph.nodes.has('fn:src/db/b.ts:shared')).toBe(true)
179
+ })
170
180
  })
@@ -46,4 +46,10 @@ describe('computeRootHash', () => {
46
46
  const hash2 = computeRootHash({ payments: 'def', auth: 'abc' })
47
47
  expect(hash1).toBe(hash2)
48
48
  })
49
+
50
+ it('changes when a module hash changes', () => {
51
+ const base = computeRootHash({ auth: 'abc', payments: 'def' })
52
+ const changed = computeRootHash({ auth: 'abc', payments: 'xyz' })
53
+ expect(base).not.toBe(changed)
54
+ })
49
55
  })
@@ -71,4 +71,17 @@ describe('ImpactAnalyzer - Classified', () => {
71
71
  expect(result.classified.low).toHaveLength(1)
72
72
  expect(result.classified.low[0].nodeId).toBe('fn:src/highriskauthservice.ts:highriskauthservice')
73
73
  })
74
+
75
+ it('deduplicates impacted nodes when changed list includes duplicates', () => {
76
+ const graph = buildTestGraph([
77
+ ['A', 'B'],
78
+ ['B', 'C'],
79
+ ['C', 'nothing'],
80
+ ])
81
+
82
+ const analyzer = new ImpactAnalyzer(graph)
83
+ const result = analyzer.analyze(['fn:src/c.ts:c', 'fn:src/c.ts:c'])
84
+ const unique = new Set(result.impacted)
85
+ expect(unique.size).toBe(result.impacted.length)
86
+ })
74
87
  })
@@ -1042,6 +1042,16 @@ describe('JavaScript - Additional Edge Cases', () => {
1042
1042
  })
1043
1043
  })
1044
1044
 
1045
+ describe('Deterministic parsing', () => {
1046
+ test('produces identical file hash for identical source', async () => {
1047
+ const parser = new JavaScriptParser()
1048
+ const source = 'export function stable() { return 1 }'
1049
+ const a = await parser.parse('src/stable.js', source)
1050
+ const b = await parser.parse('src/stable.js', source)
1051
+ expect(a.hash).toBe(b.hash)
1052
+ })
1053
+ })
1054
+
1045
1055
  describe('Chained Methods', () => {
1046
1056
  test('handles method chaining', () => {
1047
1057
  const src = `