@getmikk/core 2.0.12 → 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.
- package/README.md +12 -3
- package/package.json +1 -1
- package/src/contract/adr-manager.ts +5 -4
- package/src/contract/contract-writer.ts +3 -2
- package/src/contract/lock-compiler.ts +3 -0
- package/src/contract/lock-reader.ts +62 -5
- package/src/contract/schema.ts +8 -0
- package/src/index.ts +12 -1
- package/src/parser/index.ts +301 -74
- package/src/parser/oxc-parser.ts +3 -2
- package/src/parser/tree-sitter/parser.ts +24 -1
- package/src/parser/tree-sitter/queries.ts +27 -0
- package/src/parser/types.ts +1 -1
- package/src/utils/artifact-transaction.ts +176 -0
- package/src/utils/atomic-write.ts +131 -0
- package/src/utils/fs.ts +33 -13
- package/src/utils/language-registry.ts +82 -0
- package/tests/adr-manager.test.ts +6 -0
- package/tests/artifact-transaction.test.ts +73 -0
- package/tests/contract.test.ts +12 -0
- package/tests/dead-code.test.ts +12 -0
- package/tests/esm-resolver.test.ts +6 -0
- package/tests/fs.test.ts +22 -1
- package/tests/fuzzy-match.test.ts +6 -0
- package/tests/go-parser.test.ts +7 -0
- package/tests/graph.test.ts +10 -0
- package/tests/hash.test.ts +6 -0
- package/tests/impact-classified.test.ts +13 -0
- package/tests/js-parser.test.ts +10 -0
- package/tests/language-registry.test.ts +64 -0
- package/tests/parse-diagnostics.test.ts +115 -0
- package/tests/parser.test.ts +36 -0
- package/tests/tree-sitter-parser.test.ts +201 -0
- package/tests/ts-parser.test.ts +6 -0
|
@@ -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:
|
|
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:
|
|
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:
|
|
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:
|
|
334
|
+
patterns: toPatterns(language),
|
|
328
335
|
ignore: [...commonIgnore, '**/vendor/**', '**/*_test.go'],
|
|
329
336
|
}
|
|
330
337
|
case 'rust':
|
|
331
338
|
return {
|
|
332
|
-
patterns:
|
|
339
|
+
patterns: toPatterns(language),
|
|
333
340
|
ignore: [...commonIgnore, '**/target/**'],
|
|
334
341
|
}
|
|
335
342
|
case 'java':
|
|
336
343
|
return {
|
|
337
|
-
patterns:
|
|
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:
|
|
354
|
+
patterns: toPatterns(language),
|
|
343
355
|
ignore: [...commonIgnore, '**/vendor/**', '**/*_spec.rb', '**/spec/**'],
|
|
344
356
|
}
|
|
345
357
|
case 'php':
|
|
346
358
|
return {
|
|
347
|
-
patterns:
|
|
359
|
+
patterns: toPatterns(language),
|
|
348
360
|
ignore: [...commonIgnore, '**/vendor/**', '**/*Test.php'],
|
|
349
361
|
}
|
|
350
362
|
case 'csharp':
|
|
351
363
|
return {
|
|
352
|
-
patterns:
|
|
364
|
+
patterns: toPatterns(language),
|
|
353
365
|
ignore: [...commonIgnore, '**/bin/**', '**/obj/**'],
|
|
354
366
|
}
|
|
355
367
|
case 'cpp':
|
|
356
368
|
return {
|
|
357
|
-
patterns:
|
|
369
|
+
patterns: toPatterns(language),
|
|
358
370
|
ignore: [...commonIgnore, '**/build/**', '**/cmake-build-*/**'],
|
|
359
371
|
}
|
|
360
372
|
case 'c':
|
|
361
373
|
return {
|
|
362
|
-
patterns:
|
|
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:
|
|
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
|
+
})
|
package/tests/contract.test.ts
CHANGED
|
@@ -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', () => {
|
package/tests/dead-code.test.ts
CHANGED
|
@@ -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
|
})
|