model-eol 0.4.1 → 0.5.1

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/lib/apply.mjs CHANGED
@@ -2,7 +2,12 @@ import crypto from 'node:crypto'
2
2
  import fs from 'node:fs'
3
3
  import path from 'node:path'
4
4
 
5
+ import { assertValidPlanDocument, readJsonDocument, validatePlanItems } from './validate-document.mjs'
6
+
7
+ export { validatePlanItems }
8
+
5
9
  const sha256 = value => crypto.createHash('sha256').update(value).digest('hex')
10
+ const utf8Decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true })
6
11
 
7
12
  const occurrenceIndex = (line, value, occurrence) => {
8
13
  if (!value || !Number.isInteger(occurrence) || occurrence < 0) return -1
@@ -22,63 +27,93 @@ const printError = (item, message) => {
22
27
  console.error(`model-eol: apply refused ${itemLabel(item)}: ${message}`)
23
28
  }
24
29
 
25
- export const validatePlanItems = items => {
26
- if (!Array.isArray(items)) throw new Error('plan must contain an items array')
27
- for (const [index, item] of items.entries()) {
28
- if (!item || typeof item !== 'object' || Array.isArray(item)) {
29
- throw new Error(`plan item ${index} must be an object`)
30
- }
31
- if (typeof item.file !== 'string' || item.file.length === 0) {
32
- throw new Error(`plan item ${index} file must be a non-empty string`)
33
- }
34
- if (!Number.isInteger(item.line) || item.line < 1) {
35
- throw new Error(`plan item ${index} line must be an integer >= 1`)
36
- }
37
- if (!Number.isInteger(item.occurrence) || item.occurrence < 0) {
38
- throw new Error(`plan item ${index} occurrence must be an integer >= 0`)
39
- }
40
- if (typeof item.matched !== 'string' || item.matched.length === 0) {
41
- throw new Error(`plan item ${index} matched must be a non-empty string`)
42
- }
43
- if (typeof item.replacement !== 'string' || item.replacement.length === 0) {
44
- throw new Error(`plan item ${index} replacement must be a non-empty string`)
45
- }
46
- if (typeof item.expected_line_sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(item.expected_line_sha256)) {
47
- throw new Error(`plan item ${index} expected_line_sha256 must be 64 lowercase hexadecimal characters`)
48
- }
49
- }
50
- return items
51
- }
52
-
53
30
  const isInside = (root, candidate) => {
54
31
  const relative = path.relative(root, candidate)
55
32
  return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative))
56
33
  }
57
34
 
58
- const realPathForContainment = value => {
59
- let current = value
60
- const suffix = []
35
+ const resolvePlanFile = (rootDir, file) => {
36
+ const lexicalRoot = path.resolve(rootDir)
37
+ if (file.split(/[\\/]/).includes('..')) throw new Error('file path contains .. traversal')
38
+ const resolved = path.resolve(lexicalRoot, file)
39
+ if (isInside(lexicalRoot, resolved)) return resolved
40
+ if (!path.isAbsolute(file)) throw new Error('file resolves outside rootDir')
41
+
42
+ // Remap only system-level root aliases such as macOS /var -> /private/var.
43
+ const physicalRoot = fs.realpathSync(lexicalRoot)
44
+ let candidateRoot = path.dirname(resolved)
61
45
  while (true) {
62
46
  try {
63
- return path.join(fs.realpathSync(current), ...suffix)
47
+ if (fs.realpathSync(candidateRoot) === physicalRoot) {
48
+ const relative = path.relative(candidateRoot, resolved)
49
+ const remapped = path.resolve(lexicalRoot, relative)
50
+ if (isInside(lexicalRoot, remapped)) return remapped
51
+ }
64
52
  } catch {
65
- const parent = path.dirname(current)
66
- if (parent === current) return value
67
- suffix.unshift(path.basename(current))
68
- current = parent
69
53
  }
54
+ const parent = path.dirname(candidateRoot)
55
+ if (parent === candidateRoot) break
56
+ candidateRoot = parent
70
57
  }
58
+ throw new Error('file resolves outside rootDir')
71
59
  }
72
60
 
73
- const resolvePlanFile = (rootDir, file) => {
74
- const lexicalRoot = path.resolve(rootDir)
75
- const root = fs.realpathSync(lexicalRoot)
76
- if (file.split(/[\\/]/).includes('..')) throw new Error('file path contains .. traversal')
77
- const resolved = path.resolve(lexicalRoot, file)
78
- if (!path.isAbsolute(file) && !isInside(lexicalRoot, resolved)) throw new Error('file resolves outside rootDir')
79
- const realFile = realPathForContainment(resolved)
80
- if (!isInside(root, realFile)) throw new Error('file resolves outside rootDir')
81
- return resolved
61
+ const assertSafePath = (rootDir, file) => {
62
+ const root = path.resolve(rootDir)
63
+ if (!isInside(root, file)) throw new Error('file resolves outside rootDir')
64
+
65
+ const rootStat = fs.lstatSync(root)
66
+ if (rootStat.isSymbolicLink()) throw new Error(`file path contains symlink component: ${root}`)
67
+ if (!rootStat.isDirectory()) throw new Error(`rootDir is not a directory: ${root}`)
68
+
69
+ const relative = path.relative(root, file)
70
+ const components = relative === '' ? [] : relative.split(path.sep)
71
+ let current = root
72
+ let currentStat = rootStat
73
+ for (const [index, component] of components.entries()) {
74
+ current = path.join(current, component)
75
+ currentStat = fs.lstatSync(current)
76
+ if (currentStat.isSymbolicLink()) throw new Error(`file path contains symlink component: ${current}`)
77
+ if (index < components.length - 1 && !currentStat.isDirectory()) {
78
+ throw new Error(`parent path component is not a directory: ${current}`)
79
+ }
80
+ }
81
+ if (!currentStat.isFile()) throw new Error(`target is not a regular file: ${file}`)
82
+ return currentStat
83
+ }
84
+
85
+ const readTarget = (rootDir, file) => {
86
+ assertSafePath(rootDir, file)
87
+ const noFollow = fs.constants.O_NOFOLLOW ?? 0
88
+ let descriptor
89
+ let stat
90
+ let buffer
91
+ try {
92
+ descriptor = fs.openSync(file, fs.constants.O_RDONLY | noFollow)
93
+ stat = fs.fstatSync(descriptor)
94
+ if (!stat.isFile()) throw new Error(`target is not a regular file: ${file}`)
95
+ buffer = fs.readFileSync(descriptor)
96
+ } finally {
97
+ if (descriptor !== undefined) fs.closeSync(descriptor)
98
+ }
99
+
100
+ const pathStat = assertSafePath(rootDir, file)
101
+ if (pathStat.dev !== stat.dev || pathStat.ino !== stat.ino) {
102
+ throw new Error(`target changed while it was being read: ${file}`)
103
+ }
104
+ let text
105
+ try {
106
+ text = utf8Decoder.decode(buffer)
107
+ } catch {
108
+ throw new Error(`target is not valid UTF-8: ${file}`)
109
+ }
110
+ return {
111
+ buffer,
112
+ text,
113
+ dev: stat.dev,
114
+ ino: stat.ino,
115
+ mode: stat.mode & 0o7777,
116
+ }
82
117
  }
83
118
 
84
119
  const groupItems = items => {
@@ -120,16 +155,21 @@ const locateOccurrences = (line, items) => {
120
155
  const locateReplacements = (line, items) => {
121
156
  const located = locateOccurrences(line, items)
122
157
  if (located.failures.length) return located
123
- const seen = new Set()
124
158
  const failures = []
125
159
  const locations = []
126
160
  for (const location of located.locations) {
127
- const key = `${location.item.line}:${location.index}`
128
- if (seen.has(key)) {
129
- failures.push({ item: location.item, message: 'duplicate occurrence in plan' })
161
+ const end = location.index + location.item.matched.length
162
+ const conflict = locations.find(existing => (
163
+ location.index < existing.index + existing.item.matched.length &&
164
+ existing.index < end
165
+ ))
166
+ if (conflict) {
167
+ const message = conflict.index === location.index
168
+ ? 'duplicate occurrence in plan'
169
+ : 'replacement span overlaps another plan item'
170
+ failures.push({ item: location.item, message })
130
171
  continue
131
172
  }
132
- seen.add(key)
133
173
  locations.push(location)
134
174
  }
135
175
  return { locations, failures }
@@ -186,224 +226,346 @@ const restoreGroup = (postImage, items) => {
186
226
  return search(postImage, descriptors)
187
227
  }
188
228
 
189
- const temporaryFileFor = file => path.join(
229
+ const temporaryFileFor = (file, purpose) => path.join(
190
230
  path.dirname(file),
191
- `.model-eol-${path.basename(file)}-${process.pid}-${crypto.randomBytes(12).toString('hex')}.tmp`,
231
+ `.model-eol-${path.basename(file)}-${process.pid}-${purpose}-${crypto.randomBytes(12).toString('hex')}.tmp`,
192
232
  )
193
233
 
194
- const writeAtomically = (file, content) => {
195
- const temporaryFile = temporaryFileFor(file)
196
- try {
197
- const mode = fs.statSync(file).mode & 0o7777
198
- fs.writeFileSync(temporaryFile, content, { encoding: 'utf8', flag: 'wx', mode })
199
- fs.chmodSync(temporaryFile, mode)
200
- fs.renameSync(temporaryFile, file)
201
- } catch (error) {
234
+ const sortedItems = items => items.toSorted((a, b) => a.planIndex - b.planIndex)
235
+
236
+ const analyzeFile = (file, items, snapshot) => {
237
+ const lines = snapshot.text.split('\n')
238
+ const ready = []
239
+ const already = []
240
+ const failures = []
241
+ const readyGroups = []
242
+
243
+ for (const [lineNumber, lineItems] of groupItemsByLine(items)) {
244
+ const line = lines[lineNumber - 1]
245
+ if (line === undefined) {
246
+ for (const item of lineItems) failures.push({ item, message: 'line is not present' })
247
+ continue
248
+ }
249
+
250
+ if (lineItems.length > MAX_RESTORE_ITEMS) {
251
+ for (const item of lineItems) failures.push({ item, message: 'line hash does not match expected_line_sha256' })
252
+ continue
253
+ }
254
+
255
+ if (hasExpectedPreImage(line, lineItems)) {
256
+ const located = locateReplacements(line, lineItems)
257
+ failures.push(...located.failures)
258
+ for (const location of located.locations) {
259
+ ready.push(location.item)
260
+ }
261
+ readyGroups.push({
262
+ lineNumber,
263
+ items: located.locations.map(location => location.item),
264
+ locations: located.locations,
265
+ })
266
+ continue
267
+ }
268
+
269
+ if (restoreGroup(line, lineItems) !== null) already.push(...lineItems)
270
+ else {
271
+ for (const item of lineItems) failures.push({ item, message: 'line hash does not match expected_line_sha256' })
272
+ }
273
+ }
274
+
275
+ const updatedLines = lines.slice()
276
+ for (const { lineNumber, locations } of readyGroups) {
277
+ updatedLines[lineNumber - 1] = applyLocatedReplacements(lines[lineNumber - 1], locations)
278
+ }
279
+
280
+ return {
281
+ file,
282
+ items,
283
+ snapshot,
284
+ ready: sortedItems(ready),
285
+ already: sortedItems(already),
286
+ failures,
287
+ updatedContent: updatedLines.join('\n'),
288
+ }
289
+ }
290
+
291
+ const inspectFiles = (rootDir, prepared) => {
292
+ const analyses = []
293
+ const failures = []
294
+ for (const [file, items] of groupItems(prepared)) {
202
295
  try {
203
- fs.unlinkSync(temporaryFile)
204
- } catch {
296
+ const analysis = analyzeFile(file, items, readTarget(rootDir, file))
297
+ analyses.push(analysis)
298
+ failures.push(...analysis.failures)
299
+ } catch (error) {
300
+ for (const item of items) failures.push({ item, message: `unreadable file: ${error.message}` })
205
301
  }
206
- throw error
207
302
  }
303
+ return { analyses, failures }
208
304
  }
209
305
 
210
- export const applyPlan = ({ planPath, dryRun = false, rootDir }) => {
211
- if (typeof rootDir !== 'string' || rootDir.length === 0) throw new Error('applyPlan requires rootDir')
212
- const plan = JSON.parse(fs.readFileSync(planPath, 'utf8'))
213
- if (!plan || typeof plan !== 'object' || Array.isArray(plan)) throw new Error('plan must be an object')
214
- validatePlanItems(plan.items)
306
+ const uniqueFailures = failures => {
307
+ const seen = new Set()
308
+ return failures
309
+ .toSorted((a, b) => a.item.planIndex - b.item.planIndex)
310
+ .filter(failure => {
311
+ if (seen.has(failure.item)) return false
312
+ seen.add(failure.item)
313
+ return true
314
+ })
315
+ }
215
316
 
216
- let failed = 0
217
- let applied = 0
317
+ const reportAlready = (analyses, excluded = new Set()) => {
218
318
  let alreadyApplied = 0
319
+ const items = sortedItems(analyses.flatMap(analysis => analysis.already))
320
+ for (const item of items) {
321
+ if (excluded.has(item)) continue
322
+ console.log(`already-applied ${itemLabel(item)}: ${item.matched} -> ${item.replacement}`)
323
+ alreadyApplied++
324
+ }
325
+ return alreadyApplied
326
+ }
219
327
 
220
- const prepared = []
221
- for (const [planIndex, item] of plan.items.entries()) {
328
+ const reportRefusal = (analyses, failures, cascadeMessage) => {
329
+ const specific = uniqueFailures(failures)
330
+ const refused = new Set(specific.map(failure => failure.item))
331
+ for (const failure of specific) printError(failure.item, failure.message)
332
+
333
+ let failed = specific.length
334
+ const ready = sortedItems(analyses.flatMap(analysis => analysis.ready))
335
+ for (const item of ready) {
336
+ if (refused.has(item)) continue
337
+ printError(item, cascadeMessage)
338
+ refused.add(item)
339
+ failed++
340
+ }
341
+ const alreadyApplied = reportAlready(analyses, refused)
342
+ return { failed, applied: 0, alreadyApplied }
343
+ }
344
+
345
+ const snapshotsMatch = (left, right) => (
346
+ left.dev === right.dev &&
347
+ left.ino === right.ino &&
348
+ left.mode === right.mode &&
349
+ left.buffer.equals(right.buffer)
350
+ )
351
+
352
+ const verifySnapshots = (rootDir, analyses) => {
353
+ const failures = []
354
+ for (const analysis of analyses) {
222
355
  try {
223
- prepared.push({ ...item, planIndex, resolvedFile: resolvePlanFile(rootDir, item.file) })
356
+ const latest = readTarget(rootDir, analysis.file)
357
+ if (!snapshotsMatch(latest, analysis.snapshot)) {
358
+ for (const item of analysis.items) failures.push({ item, message: 'target changed after validation' })
359
+ }
224
360
  } catch (error) {
225
- printError(item, error.message)
226
- failed++
361
+ for (const item of analysis.items) failures.push({ item, message: `write failed: ${error.message}` })
227
362
  }
228
363
  }
364
+ return failures
365
+ }
229
366
 
230
- for (const [file, items] of groupItems(prepared)) {
231
- let text
367
+ const writeStagedFile = (file, content, mode, created) => {
368
+ const descriptor = fs.openSync(
369
+ file,
370
+ fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL,
371
+ mode,
372
+ )
373
+ created.push(file)
374
+ try {
375
+ fs.writeFileSync(descriptor, content)
376
+ } finally {
377
+ fs.closeSync(descriptor)
378
+ }
379
+ fs.chmodSync(file, mode)
380
+ }
381
+
382
+ const removeTemporaryFiles = (temporaryFiles, unlinkSync) => {
383
+ const failures = []
384
+ for (const temporaryFile of temporaryFiles) {
385
+ if (!temporaryFile) continue
232
386
  try {
233
- text = fs.readFileSync(file, 'utf8')
234
- } catch (e) {
235
- for (const item of items) {
236
- printError(item, `unreadable file: ${e.message}`)
237
- failed++
238
- }
239
- continue
387
+ unlinkSync(temporaryFile)
388
+ } catch (error) {
389
+ if (error?.code !== 'ENOENT') failures.push({ temporaryFile, error })
240
390
  }
391
+ }
392
+ return failures
393
+ }
241
394
 
242
- const lines = text.split('\n')
243
- let ready = []
244
- const already = []
245
- const failures = []
246
-
247
- const readyGroups = []
248
- for (const [lineNumber, lineItems] of groupItemsByLine(items)) {
249
- const line = lines[lineNumber - 1]
250
- if (line === undefined) {
251
- for (const item of lineItems) failures.push({ item, message: 'line is not present' })
252
- continue
253
- }
395
+ const cleanupFailureDetail = failures => failures.length
396
+ ? `; temporary cleanup failed (${failures.map(({ temporaryFile, error }) => `${temporaryFile}: ${error.message}`).join('; ')})`
397
+ : ''
254
398
 
255
- if (lineItems.length > MAX_RESTORE_ITEMS) {
256
- for (const item of lineItems) failures.push({ item, message: 'line hash does not match expected_line_sha256' })
257
- continue
258
- }
399
+ const reportCleanupFailures = failures => {
400
+ for (const { temporaryFile, error } of failures) {
401
+ console.error(`model-eol: apply cleanup failed ${temporaryFile}: ${error.message}`)
402
+ }
403
+ }
259
404
 
260
- if (hasExpectedPreImage(line, lineItems)) {
261
- const located = locateOccurrences(line, lineItems)
262
- failures.push(...located.failures)
263
- const failedItems = new Set(located.failures.map(failure => failure.item))
264
- const groupReady = lineItems.filter(item => !failedItems.has(item))
265
- ready.push(...groupReady)
266
- readyGroups.push({ lineNumber, items: groupReady, locations: located.locations })
267
- continue
268
- }
405
+ const stageAnalysis = (rootDir, analysis, unlinkSync) => {
406
+ assertSafePath(rootDir, analysis.file)
407
+ const nextPath = temporaryFileFor(analysis.file, 'next')
408
+ const backupPath = temporaryFileFor(analysis.file, 'backup')
409
+ const created = []
410
+ try {
411
+ writeStagedFile(nextPath, Buffer.from(analysis.updatedContent, 'utf8'), analysis.snapshot.mode, created)
412
+ writeStagedFile(backupPath, analysis.snapshot.buffer, analysis.snapshot.mode, created)
413
+ return { analysis, nextPath, backupPath, keepBackup: false }
414
+ } catch (error) {
415
+ const cleanupFailures = removeTemporaryFiles(created, unlinkSync)
416
+ throw new Error(`${error.message}${cleanupFailureDetail(cleanupFailures)}`)
417
+ }
418
+ }
269
419
 
270
- if (restoreGroup(line, lineItems) !== null) already.push(...lineItems)
271
- else {
272
- for (const item of lineItems) failures.push({ item, message: 'line hash does not match expected_line_sha256' })
273
- }
274
- }
420
+ const cleanupStages = (stages, unlinkSync) => removeTemporaryFiles(
421
+ stages.flatMap(stage => [stage.nextPath, stage.keepBackup ? null : stage.backupPath]),
422
+ unlinkSync,
423
+ )
275
424
 
276
- if (failures.length) {
277
- ready.sort((a, b) => a.planIndex - b.planIndex)
278
- already.sort((a, b) => a.planIndex - b.planIndex)
279
- failures.sort((a, b) => a.item.planIndex - b.item.planIndex)
280
- for (const failure of failures) {
281
- printError(failure.item, failure.message)
282
- failed++
283
- }
284
- for (const item of ready) {
285
- printError(item, 'another item for this file failed validation; file was not changed')
286
- failed++
287
- }
288
- for (const item of already) {
289
- console.log(`already-applied ${itemLabel(item)}: ${item.matched} -> ${item.replacement}`)
290
- alreadyApplied++
291
- }
292
- continue
293
- }
425
+ export const applyPlan = ({ planPath, dryRun = false, rootDir, _test } = {}) => {
426
+ if (typeof rootDir !== 'string' || rootDir.length === 0) throw new Error('applyPlan requires rootDir')
427
+ const plan = readJsonDocument(planPath)
428
+ assertValidPlanDocument(plan)
294
429
 
295
- const duplicateFailures = []
296
- const duplicateItems = new Set()
297
- for (const readyGroup of readyGroups) {
298
- const located = locateReplacements(lines[readyGroup.lineNumber - 1], readyGroup.items)
299
- duplicateFailures.push(...located.failures)
300
- for (const failure of located.failures) duplicateItems.add(failure.item)
301
- readyGroup.locations = located.locations
302
- readyGroup.items = readyGroup.items.filter(item => !duplicateItems.has(item))
303
- }
304
- if (duplicateFailures.length) {
305
- ready = ready.filter(item => !duplicateItems.has(item))
306
- failures.push(...duplicateFailures)
307
- ready.sort((a, b) => a.planIndex - b.planIndex)
308
- already.sort((a, b) => a.planIndex - b.planIndex)
309
- failures.sort((a, b) => a.item.planIndex - b.item.planIndex)
310
- for (const failure of failures) {
311
- printError(failure.item, failure.message)
312
- failed++
313
- }
314
- for (const item of ready) {
315
- printError(item, 'another item for this file failed validation; file was not changed')
316
- failed++
317
- }
318
- continue
430
+ const prepared = []
431
+ const pathFailures = []
432
+ for (const [planIndex, item] of plan.items.entries()) {
433
+ try {
434
+ prepared.push({ ...item, planIndex, resolvedFile: resolvePlanFile(rootDir, item.file) })
435
+ } catch (error) {
436
+ pathFailures.push({ item: { ...item, planIndex }, message: error.message })
319
437
  }
438
+ }
320
439
 
321
- ready.sort((a, b) => a.planIndex - b.planIndex)
322
- already.sort((a, b) => a.planIndex - b.planIndex)
440
+ const initial = inspectFiles(rootDir, prepared)
441
+ const initialFailures = pathFailures.concat(initial.failures)
442
+ if (initialFailures.length) {
443
+ return reportRefusal(
444
+ initial.analyses,
445
+ initialFailures,
446
+ 'another item in this plan failed validation; no files were changed',
447
+ )
448
+ }
323
449
 
324
- const updatedLines = lines.slice()
325
- for (const { lineNumber, locations } of readyGroups) {
326
- updatedLines[lineNumber - 1] = applyLocatedReplacements(lines[lineNumber - 1], locations)
450
+ const initiallyReady = initial.analyses.flatMap(analysis => analysis.ready)
451
+ if (dryRun) {
452
+ const alreadyApplied = reportAlready(initial.analyses)
453
+ for (const item of sortedItems(initiallyReady)) {
454
+ console.log(`would change ${itemLabel(item)}: ${item.matched} -> ${item.replacement}`)
327
455
  }
328
- const updatedContent = updatedLines.join('\n')
456
+ return { failed: 0, applied: 0, alreadyApplied }
457
+ }
458
+ if (!initiallyReady.length) {
459
+ return { failed: 0, applied: 0, alreadyApplied: reportAlready(initial.analyses) }
460
+ }
329
461
 
330
- for (const item of already) {
331
- console.log(`already-applied ${itemLabel(item)}: ${item.matched} -> ${item.replacement}`)
332
- alreadyApplied++
333
- }
334
- if (!ready.length) continue
462
+ // Re-read every plan target together before staging output.
463
+ const current = inspectFiles(rootDir, prepared)
464
+ if (current.failures.length) {
465
+ return reportRefusal(
466
+ current.analyses,
467
+ current.failures,
468
+ 'another item in this plan failed revalidation; no files were changed',
469
+ )
470
+ }
335
471
 
336
- if (dryRun) {
337
- for (const item of ready) {
338
- console.log(`would change ${itemLabel(item)}: ${item.matched} -> ${item.replacement}`)
339
- }
340
- continue
341
- }
472
+ const currentReady = current.analyses.flatMap(analysis => analysis.ready)
473
+ if (!currentReady.length) {
474
+ return { failed: 0, applied: 0, alreadyApplied: reportAlready(current.analyses) }
475
+ }
342
476
 
343
- let latestText
477
+ const unlinkSync = typeof _test?.unlinkSync === 'function'
478
+ ? temporaryFile => _test.unlinkSync(temporaryFile)
479
+ : temporaryFile => fs.unlinkSync(temporaryFile)
480
+
481
+ const stages = []
482
+ let stagingFailure = null
483
+ for (const analysis of current.analyses) {
484
+ if (!analysis.ready.length) continue
344
485
  try {
345
- latestText = fs.readFileSync(file, 'utf8')
346
- } catch (e) {
347
- for (const item of ready) {
348
- printError(item, `write failed: ${e.message}`)
349
- failed++
350
- }
351
- continue
352
- }
353
- const latestLines = latestText.split('\n')
354
- const latestReadyGroups = []
355
- const verificationFailures = []
356
- for (const { lineNumber, items: lineItems } of readyGroups) {
357
- const line = latestLines[lineNumber - 1]
358
- if (line === undefined) {
359
- for (const item of lineItems) verificationFailures.push({ item, message: 'line is not present' })
360
- continue
361
- }
362
- if (!hasExpectedPreImage(line, lineItems)) {
363
- for (const item of lineItems) verificationFailures.push({ item, message: 'line hash does not match expected_line_sha256' })
364
- continue
365
- }
366
- const located = locateReplacements(line, lineItems)
367
- if (located.failures.length) {
368
- verificationFailures.push(...located.failures)
369
- continue
370
- }
371
- latestReadyGroups.push({ lineNumber, locations: located.locations })
372
- }
373
- verificationFailures.sort((a, b) => a.item.planIndex - b.item.planIndex)
374
- if (verificationFailures.length) {
375
- for (const failure of verificationFailures) {
376
- printError(failure.item, failure.message)
377
- failed++
378
- }
379
- const verificationFailedItems = new Set(verificationFailures.map(failure => failure.item))
380
- for (const item of ready) {
381
- if (!verificationFailedItems.has(item)) {
382
- printError(item, 'another item for this file failed validation; file was not changed')
383
- failed++
384
- }
385
- }
386
- continue
486
+ stages.push(stageAnalysis(rootDir, analysis, unlinkSync))
487
+ } catch (error) {
488
+ stagingFailure = { analysis, error }
489
+ break
387
490
  }
491
+ }
492
+ if (stagingFailure) {
493
+ reportCleanupFailures(cleanupStages(stages, unlinkSync))
494
+ return reportRefusal(
495
+ current.analyses,
496
+ stagingFailure.analysis.ready.map(item => ({ item, message: `write failed: ${stagingFailure.error.message}` })),
497
+ 'another file could not be staged; no files were changed',
498
+ )
499
+ }
388
500
 
389
- const latestUpdatedLines = latestLines.slice()
390
- for (const { lineNumber, locations } of latestReadyGroups) {
391
- latestUpdatedLines[lineNumber - 1] = applyLocatedReplacements(latestLines[lineNumber - 1], locations)
392
- }
501
+ // Verify every snapshot again before the first commit rename.
502
+ const precommitFailures = verifySnapshots(rootDir, current.analyses)
503
+ if (precommitFailures.length) {
504
+ reportCleanupFailures(cleanupStages(stages, unlinkSync))
505
+ return reportRefusal(
506
+ current.analyses,
507
+ precommitFailures,
508
+ 'another target changed before commit; no files were changed',
509
+ )
510
+ }
511
+
512
+ const renameSync = typeof _test?.renameSync === 'function'
513
+ ? (source, target, context) => _test.renameSync(source, target, context)
514
+ : (source, target) => fs.renameSync(source, target)
515
+ const committed = []
516
+ let commitFailure = null
517
+ for (const [index, stage] of stages.entries()) {
393
518
  try {
394
- writeAtomically(file, latestText === text ? updatedContent : latestUpdatedLines.join('\n'))
395
- } catch (e) {
396
- for (const item of ready) {
397
- printError(item, `write failed: ${e.message}`)
398
- failed++
399
- }
400
- continue
519
+ const latest = readTarget(rootDir, stage.analysis.file)
520
+ if (!snapshotsMatch(latest, stage.analysis.snapshot)) throw new Error('target changed after pre-commit verification')
521
+ renameSync(stage.nextPath, stage.analysis.file, { phase: 'commit', index, file: stage.analysis.file })
522
+ stage.nextPath = null
523
+ committed.push(stage)
524
+ } catch (error) {
525
+ commitFailure = { stage, error }
526
+ break
401
527
  }
402
- for (const item of ready) {
403
- console.log(`applied ${itemLabel(item)}: ${item.matched} -> ${item.replacement}`)
404
- applied++
528
+ }
529
+
530
+ if (commitFailure) {
531
+ const rollbackFailures = []
532
+ for (const [rollbackIndex, stage] of committed.toReversed().entries()) {
533
+ try {
534
+ assertSafePath(rootDir, stage.analysis.file)
535
+ renameSync(stage.backupPath, stage.analysis.file, {
536
+ phase: 'rollback',
537
+ index: rollbackIndex,
538
+ file: stage.analysis.file,
539
+ })
540
+ stage.backupPath = null
541
+ } catch (error) {
542
+ stage.keepBackup = true
543
+ rollbackFailures.push({ stage, error })
544
+ }
405
545
  }
546
+ reportCleanupFailures(cleanupStages(stages, unlinkSync))
547
+
548
+ const rollbackDetail = rollbackFailures.length
549
+ ? `; rollback incomplete (${rollbackFailures.map(({ stage, error }) => `${stage.analysis.file}: ${error.message}; backup retained at ${stage.backupPath}`).join('; ')})`
550
+ : committed.length ? '; earlier file commits were rolled back' : ''
551
+ const cascadeMessage = rollbackFailures.length
552
+ ? 'another file failed during plan commit and rollback was incomplete'
553
+ : 'another file failed during plan commit; earlier commits were rolled back'
554
+ return reportRefusal(
555
+ current.analyses,
556
+ commitFailure.stage.analysis.ready.map(item => ({
557
+ item,
558
+ message: `write failed: ${commitFailure.error.message}${rollbackDetail}`,
559
+ })),
560
+ cascadeMessage,
561
+ )
406
562
  }
407
563
 
408
- return { failed, applied, alreadyApplied }
564
+ const cleanupFailures = cleanupStages(stages, unlinkSync)
565
+ reportCleanupFailures(cleanupFailures)
566
+ const alreadyApplied = reportAlready(current.analyses)
567
+ for (const item of sortedItems(currentReady)) {
568
+ console.log(`applied ${itemLabel(item)}: ${item.matched} -> ${item.replacement}`)
569
+ }
570
+ return { failed: cleanupFailures.length, applied: currentReady.length, alreadyApplied }
409
571
  }