@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
@@ -116,7 +116,7 @@ export interface ParsedRoute {
116
116
  /** Everything extracted from a single file */
117
117
  export interface ParsedFile {
118
118
  path: string; // normalized absolute path
119
- language: 'python' | 'go' | 'typescript' | 'javascript' | 'java' | 'c' | 'cpp' | 'csharp' | 'rust' | 'php' | 'ruby' | 'unknown';
119
+ language: 'python' | 'go' | 'typescript' | 'javascript' | 'java' | 'kotlin' | 'swift' | 'c' | 'cpp' | 'csharp' | 'rust' | 'php' | 'ruby' | 'unknown';
120
120
  functions: ParsedFunction[];
121
121
  classes: ParsedClass[];
122
122
  variables: ParsedVariable[];
@@ -0,0 +1 @@
1
+ export { SecurityScanner, type SecurityFinding, type SecurityReport } from './scanner.js'
@@ -0,0 +1,342 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Security Vulnerability Scanning — foundation for detecting common patterns
3
+ // ---------------------------------------------------------------------------
4
+
5
+ export interface SecurityFinding {
6
+ id: string
7
+ severity: 'critical' | 'high' | 'medium' | 'low' | 'info'
8
+ category: string
9
+ title: string
10
+ description: string
11
+ file: string
12
+ line: number
13
+ column?: number
14
+ code: string
15
+ suggestion?: string
16
+ cwe?: string
17
+ cve?: string
18
+ }
19
+
20
+ export interface SecurityReport {
21
+ findings: SecurityFinding[]
22
+ summary: {
23
+ total: number
24
+ critical: number
25
+ high: number
26
+ medium: number
27
+ low: number
28
+ info: number
29
+ }
30
+ scannedFiles: number
31
+ scanDuration: number
32
+ }
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Pattern definitions for common vulnerability categories
36
+ // ---------------------------------------------------------------------------
37
+
38
+ interface VulnerabilityPattern {
39
+ id: string
40
+ severity: SecurityFinding['severity']
41
+ category: string
42
+ title: string
43
+ description: string
44
+ regex: RegExp
45
+ suggestion?: string
46
+ cwe?: string
47
+ languages?: string[]
48
+ }
49
+
50
+ const VULNERABILITY_PATTERNS: VulnerabilityPattern[] = [
51
+ // SQL Injection
52
+ {
53
+ id: 'sql-injection',
54
+ severity: 'critical',
55
+ category: 'injection',
56
+ title: 'Potential SQL Injection',
57
+ description: 'String concatenation in SQL query detected. Use parameterized queries instead.',
58
+ regex: /(?:execute|query|cursor\.execute)\s*\(\s*["'].*(?:\+|\$\{)/,
59
+ suggestion: 'Use parameterized queries: cursor.execute("SELECT * FROM users WHERE id = %s", (user_id,))',
60
+ cwe: 'CWE-89',
61
+ languages: ['python', 'javascript', 'typescript'],
62
+ },
63
+ {
64
+ id: 'sql-injection-fstring',
65
+ severity: 'critical',
66
+ category: 'injection',
67
+ title: 'SQL Injection via f-string',
68
+ description: 'f-string used in SQL query. Use parameterized queries.',
69
+ regex: /(?:execute|query)\s*\(\s*f["']/,
70
+ suggestion: 'Use parameterized queries instead of f-strings in SQL.',
71
+ cwe: 'CWE-89',
72
+ languages: ['python'],
73
+ },
74
+
75
+ // Command Injection
76
+ {
77
+ id: 'command-injection',
78
+ severity: 'critical',
79
+ category: 'injection',
80
+ title: 'Potential Command Injection',
81
+ description: 'User input may be passed to shell command. Use subprocess with list args instead.',
82
+ regex: /(?:os\.system|subprocess\.call|subprocess\.Popen|exec|eval)\s*\(\s*(?:.*\+|.*\$\{)/,
83
+ suggestion: 'Use subprocess.run() with a list of arguments instead of shell=True.',
84
+ cwe: 'CWE-78',
85
+ languages: ['python'],
86
+ },
87
+ {
88
+ id: 'eval-usage',
89
+ severity: 'high',
90
+ category: 'injection',
91
+ title: 'Use of eval()',
92
+ description: 'eval() can execute arbitrary code. Use ast.literal_eval() for safe parsing.',
93
+ regex: /\beval\s*\(/,
94
+ suggestion: 'Use ast.literal_eval() for parsing Python literals, or json.loads() for JSON.',
95
+ cwe: 'CWE-95',
96
+ languages: ['python', 'javascript', 'typescript'],
97
+ },
98
+
99
+ // Hardcoded Secrets
100
+ {
101
+ id: 'hardcoded-password',
102
+ severity: 'high',
103
+ category: 'secrets',
104
+ title: 'Hardcoded Password',
105
+ description: 'Password appears to be hardcoded in source code.',
106
+ regex: /(?:password|passwd|pwd)\s*[:=]\s*["'][^"']{3,}["']/i,
107
+ suggestion: 'Use environment variables or a secrets manager.',
108
+ cwe: 'CWE-798',
109
+ },
110
+ {
111
+ id: 'hardcoded-api-key',
112
+ severity: 'high',
113
+ category: 'secrets',
114
+ title: 'Hardcoded API Key',
115
+ description: 'API key or token appears to be hardcoded.',
116
+ regex: /(?:api[_-]?key|api[_-]?secret|access[_-]?token|auth[_-]?token)\s*[:=]\s*["'][A-Za-z0-9_-]{8,}["']/i,
117
+ suggestion: 'Use environment variables or a secrets manager.',
118
+ cwe: 'CWE-798',
119
+ },
120
+ {
121
+ id: 'aws-key',
122
+ severity: 'critical',
123
+ category: 'secrets',
124
+ title: 'AWS Access Key',
125
+ description: 'AWS access key pattern detected.',
126
+ regex: /AKIA[0-9A-Z]{16}/,
127
+ suggestion: 'Remove AWS credentials from source code. Use IAM roles or environment variables.',
128
+ cwe: 'CWE-798',
129
+ },
130
+ {
131
+ id: 'private-key',
132
+ severity: 'critical',
133
+ category: 'secrets',
134
+ title: 'Private Key',
135
+ description: 'Private key content detected in source code.',
136
+ regex: /-----BEGIN\s+(?:RSA\s+)?PRIVATE\s+KEY-----/,
137
+ suggestion: 'Never embed private keys in source code. Use a secrets manager.',
138
+ cwe: 'CWE-798',
139
+ },
140
+
141
+ // XSS
142
+ {
143
+ id: 'xss-innerhtml',
144
+ severity: 'high',
145
+ category: 'xss',
146
+ title: 'Potential XSS via innerHTML',
147
+ description: 'Setting innerHTML with dynamic content can lead to XSS.',
148
+ regex: /\.innerHTML\s*=\s*(?!["']\s*;?\s*$)/,
149
+ suggestion: 'Use textContent or sanitize HTML with DOMPurify.',
150
+ cwe: 'CWE-79',
151
+ languages: ['javascript', 'typescript'],
152
+ },
153
+ {
154
+ id: 'xss-dangerouslySetInnerHTML',
155
+ severity: 'high',
156
+ category: 'xss',
157
+ title: 'Potential XSS via dangerouslySetInnerHTML',
158
+ description: 'dangerouslySetInnerHTML with dynamic content can lead to XSS.',
159
+ regex: /dangerouslySetInnerHTML\s*=\s*\{\{?\s*__html\s*:/,
160
+ suggestion: 'Sanitize HTML content with DOMPurify before using dangerouslySetInnerHTML.',
161
+ cwe: 'CWE-79',
162
+ languages: ['javascript', 'typescript'],
163
+ },
164
+
165
+ // Insecure Random
166
+ {
167
+ id: 'insecure-random',
168
+ severity: 'medium',
169
+ category: 'crypto',
170
+ title: 'Insecure Random Number Generator',
171
+ description: 'Math.random() is not cryptographically secure.',
172
+ regex: /Math\.random\s*\(\)/,
173
+ suggestion: 'Use crypto.getRandomValues() for security-sensitive operations.',
174
+ cwe: 'CWE-330',
175
+ languages: ['javascript', 'typescript'],
176
+ },
177
+
178
+ // Path Traversal
179
+ {
180
+ id: 'path-traversal',
181
+ severity: 'high',
182
+ category: 'path-traversal',
183
+ title: 'Potential Path Traversal',
184
+ description: 'User input used in file path without sanitization.',
185
+ regex: /(?:readFile|readFileSync|open|writeFile|writeFileSync)\s*\(\s*(?:.*\+|.*\$\{)/,
186
+ suggestion: 'Validate and sanitize file paths. Use path.resolve() with a whitelist.',
187
+ cwe: 'CWE-22',
188
+ languages: ['javascript', 'typescript', 'python'],
189
+ },
190
+
191
+ // Weak Cryptography
192
+ {
193
+ id: 'weak-hash-md5',
194
+ severity: 'medium',
195
+ category: 'crypto',
196
+ title: 'Weak Hashing Algorithm (MD5)',
197
+ description: 'MD5 is cryptographically broken. Use SHA-256 or better.',
198
+ regex: /(?:md5|MD5|hashlib\.md5)/,
199
+ suggestion: 'Use SHA-256 or SHA-3 for cryptographic hashing.',
200
+ cwe: 'CWE-328',
201
+ },
202
+ {
203
+ id: 'weak-hash-sha1',
204
+ severity: 'medium',
205
+ category: 'crypto',
206
+ title: 'Weak Hashing Algorithm (SHA-1)',
207
+ description: 'SHA-1 is deprecated for cryptographic use. Use SHA-256 or better.',
208
+ regex: /(?:sha1|SHA1|hashlib\.sha1)/,
209
+ suggestion: 'Use SHA-256 or SHA-3 for cryptographic hashing.',
210
+ cwe: 'CWE-328',
211
+ },
212
+
213
+ // Debug/Console in Production
214
+ {
215
+ id: 'console-log',
216
+ severity: 'info',
217
+ category: 'best-practice',
218
+ title: 'Console Log Statement',
219
+ description: 'Console.log statements should be removed before production.',
220
+ regex: /console\.(log|debug|info|warn)\s*\(/,
221
+ suggestion: 'Use a proper logging framework and remove debug statements.',
222
+ languages: ['javascript', 'typescript'],
223
+ },
224
+ {
225
+ id: 'print-debug',
226
+ severity: 'info',
227
+ category: 'best-practice',
228
+ title: 'Print Debug Statement',
229
+ description: 'Print statements should be removed before production.',
230
+ regex: /print\s*\(\s*["'][^"']*["']\s*\)/,
231
+ suggestion: 'Use the logging module instead of print statements.',
232
+ languages: ['python'],
233
+ },
234
+
235
+ // TODO/FIXME/HACK
236
+ {
237
+ id: 'todo-comment',
238
+ severity: 'info',
239
+ category: 'best-practice',
240
+ title: 'TODO Comment',
241
+ description: 'TODO comment found. Consider addressing this.',
242
+ regex: /\/\/\s*TODO|\/\*\s*TODO|#\s*TODO/i,
243
+ languages: ['javascript', 'typescript', 'python', 'go', 'java', 'rust'],
244
+ },
245
+ ]
246
+
247
+ // ---------------------------------------------------------------------------
248
+ // Scanner
249
+ // ---------------------------------------------------------------------------
250
+
251
+ export class SecurityScanner {
252
+ private patterns: VulnerabilityPattern[]
253
+
254
+ constructor(customPatterns?: VulnerabilityPattern[]) {
255
+ this.patterns = customPatterns ?? VULNERABILITY_PATTERNS
256
+ }
257
+
258
+ /**
259
+ * Scan a single file's content for security issues.
260
+ */
261
+ scanFile(filePath: string, content: string, language?: string): SecurityFinding[] {
262
+ const findings: SecurityFinding[] = []
263
+ const lines = content.split('\n')
264
+
265
+ for (const pattern of this.patterns) {
266
+ // Skip if language filter doesn't match
267
+ if (pattern.languages && language && !pattern.languages.includes(language)) {
268
+ continue
269
+ }
270
+
271
+ for (let i = 0; i < lines.length; i++) {
272
+ const line = lines[i]
273
+ const match = line.match(pattern.regex)
274
+ if (match) {
275
+ findings.push({
276
+ id: `${pattern.id}-${filePath}:${i + 1}`,
277
+ severity: pattern.severity,
278
+ category: pattern.category,
279
+ title: pattern.title,
280
+ description: pattern.description,
281
+ file: filePath,
282
+ line: i + 1,
283
+ column: match.index,
284
+ code: line.trim(),
285
+ suggestion: pattern.suggestion,
286
+ cwe: pattern.cwe,
287
+ })
288
+ }
289
+ }
290
+ }
291
+
292
+ return findings
293
+ }
294
+
295
+ /**
296
+ * Scan multiple files.
297
+ */
298
+ scanFiles(
299
+ files: Array<{ path: string; content: string; language?: string }>
300
+ ): SecurityReport {
301
+ const startTime = Date.now()
302
+ const allFindings: SecurityFinding[] = []
303
+
304
+ for (const file of files) {
305
+ const findings = this.scanFile(file.path, file.content, file.language)
306
+ allFindings.push(...findings)
307
+ }
308
+
309
+ const summary = {
310
+ total: allFindings.length,
311
+ critical: allFindings.filter(f => f.severity === 'critical').length,
312
+ high: allFindings.filter(f => f.severity === 'high').length,
313
+ medium: allFindings.filter(f => f.severity === 'medium').length,
314
+ low: allFindings.filter(f => f.severity === 'low').length,
315
+ info: allFindings.filter(f => f.severity === 'info').length,
316
+ }
317
+
318
+ return {
319
+ findings: allFindings.sort((a, b) => {
320
+ const severityOrder = { critical: 0, high: 1, medium: 2, low: 3, info: 4 }
321
+ return severityOrder[a.severity] - severityOrder[b.severity]
322
+ }),
323
+ summary,
324
+ scannedFiles: files.length,
325
+ scanDuration: Date.now() - startTime,
326
+ }
327
+ }
328
+
329
+ /**
330
+ * Add custom vulnerability patterns.
331
+ */
332
+ addPattern(pattern: VulnerabilityPattern): void {
333
+ this.patterns.push(pattern)
334
+ }
335
+
336
+ /**
337
+ * Get all available patterns.
338
+ */
339
+ getPatterns(): VulnerabilityPattern[] {
340
+ return [...this.patterns]
341
+ }
342
+ }
@@ -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
+ }