pantheon-opencode 1.6.0-beta.11 → 1.6.0-beta.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.
Files changed (53) hide show
  1. package/AGENTS.global.md +3 -3
  2. package/AGENTS.md +1 -1
  3. package/CHANGELOG.md +39 -0
  4. package/README.md +85 -31
  5. package/README.pt-BR.md +7 -4
  6. package/bin/pantheon-init.mjs +278 -102
  7. package/package.json +5 -3
  8. package/plugin.json +1 -1
  9. package/pyproject.toml +1 -1
  10. package/scripts/build-agents-md.mjs +77 -13
  11. package/scripts/check-plugin-v2-coverage.mjs +7 -7
  12. package/scripts/doctor.mjs +242 -19
  13. package/scripts/install/backup.mjs +191 -18
  14. package/scripts/install/cli-ui.mjs +73 -0
  15. package/scripts/install/file-inventory.mjs +555 -0
  16. package/scripts/install/interactive.mjs +69 -391
  17. package/scripts/install/migrate.mjs +45 -38
  18. package/scripts/install/model-picker.mjs +63 -22
  19. package/scripts/install/opencode-version.mjs +9 -1
  20. package/scripts/install/opencode.mjs +279 -79
  21. package/scripts/install/plan-artifact.mjs +517 -0
  22. package/scripts/install/plan.mjs +165 -3
  23. package/scripts/install/plugin.mjs +118 -14
  24. package/scripts/install/prompt-result.mjs +2 -0
  25. package/scripts/install/shared.mjs +188 -65
  26. package/scripts/install/state.mjs +63 -8
  27. package/scripts/install/venv.mjs +15 -6
  28. package/scripts/install/verify.mjs +128 -0
  29. package/scripts/install/vision-prompt.mjs +2 -0
  30. package/scripts/install-mcp.mjs +1 -1
  31. package/scripts/manifest-inventory.mjs +49 -3
  32. package/scripts/node-engine-policy.mjs +85 -0
  33. package/scripts/package-evidence.mjs +6 -0
  34. package/scripts/postinstall.mjs +26 -26
  35. package/scripts/reconcile.mjs +325 -0
  36. package/scripts/sync-tui.mjs +5 -1
  37. package/scripts/uninstall.mjs +258 -34
  38. package/scripts/update.mjs +164 -0
  39. package/scripts/validate-node-engine-policy.mjs +13 -0
  40. package/scripts/version-check.mjs +34 -1
  41. package/scripts/versioning.mjs +9 -1
  42. package/skills-lock.json +3 -3
  43. package/src/agents/mnemosyne.md +7 -2
  44. package/src/instructions/memory-protocol.instructions.md +2 -2
  45. package/src/instructions/zeus-memory-operations.instructions.md +4 -3
  46. package/src/pantheon/v2-unsupported.mjs +2 -3
  47. package/src/plugins/tui/dist/tui-v2.js +2 -2
  48. package/src/plugins/tui/dist/tui.js +1 -1
  49. package/src/plugins/tui/package-lock.json +3 -3
  50. package/src/plugins/tui/package.json +2 -2
  51. package/src/skills/artifact-management/SKILL.md +5 -1
  52. package/src/skills/memory-bank/SKILL.md +5 -1
  53. package/src/skills/worktrees/SKILL.md +30 -11
@@ -0,0 +1,517 @@
1
+ /** Versioned, content-free install-plan artifacts and path/snapshot validation. */
2
+ import { createHash, randomBytes } from 'node:crypto'
3
+ import {
4
+ closeSync,
5
+ constants,
6
+ fchmodSync,
7
+ fstatSync,
8
+ linkSync,
9
+ lstatSync,
10
+ mkdirSync,
11
+ openSync,
12
+ readdirSync,
13
+ readFileSync,
14
+ readlinkSync,
15
+ realpathSync,
16
+ unlinkSync,
17
+ writeFileSync,
18
+ } from 'node:fs'
19
+ import { homedir } from 'node:os'
20
+ import { basename, dirname, isAbsolute, join, parse, relative, resolve, sep } from 'node:path'
21
+
22
+ export const INSTALL_PLAN_SCHEMA_VERSION = 1
23
+ const ACTIONS = new Set(['create', 'update', 'remove', 'unchanged', 'conflict'])
24
+ const IGNORED_SOURCE_DIRS = new Set([
25
+ '.git',
26
+ '.pantheon',
27
+ 'node_modules',
28
+ 'tests',
29
+ 'logs',
30
+ 'bench',
31
+ 'benchmarks',
32
+ 'bench-run-A',
33
+ ])
34
+
35
+ function hash(value) {
36
+ return createHash('sha256').update(value).digest('hex')
37
+ }
38
+
39
+ function lstatOrNull(path) {
40
+ try {
41
+ return lstatSync(path)
42
+ } catch (error) {
43
+ if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') return null
44
+ throw error
45
+ }
46
+ }
47
+
48
+ function isWithin(root, path) {
49
+ const rel = relative(resolve(root), resolve(path))
50
+ return rel === '' || (!rel.startsWith(`..${sep}`) && rel !== '..' && !isAbsolute(rel))
51
+ }
52
+
53
+ /** Reject symlinks in the target and every existing destination ancestor. */
54
+ export function assertSafeDestination(target, relativePath) {
55
+ const root = resolve(target)
56
+ const rootStat = lstatOrNull(root)
57
+ if (rootStat?.isSymbolicLink()) throw new Error(`Target is a symlink: ${root}`)
58
+ if (rootStat && !rootStat.isDirectory()) throw new Error(`Target is not a directory: ${root}`)
59
+ if (
60
+ isAbsolute(relativePath) ||
61
+ relativePath.split(/[\\/]/).some((part) => part === '..' || part === '')
62
+ ) {
63
+ throw new Error(`Unsafe plan destination path: ${relativePath}`)
64
+ }
65
+ const destination = resolve(root, relativePath)
66
+ if (!isWithin(root, destination))
67
+ throw new Error(`Plan destination escapes target: ${relativePath}`)
68
+ let current = root
69
+ for (const segment of relativePath.split(/[\\/]/)) {
70
+ current = join(current, segment)
71
+ const info = lstatOrNull(current)
72
+ if (info?.isSymbolicLink()) throw new Error(`Plan destination contains a symlink: ${current}`)
73
+ if (info && current !== destination && !info.isDirectory()) {
74
+ throw new Error(`Plan destination parent is not a directory: ${current}`)
75
+ }
76
+ if (current === destination && info && !info.isFile() && !info.isDirectory()) {
77
+ throw new Error(`Unsupported plan destination type: ${current}`)
78
+ }
79
+ }
80
+ return destination
81
+ }
82
+
83
+ function relativePlanPath(target, absolutePath) {
84
+ const rel = relative(resolve(target), resolve(absolutePath))
85
+ if (!isWithin(target, absolutePath) || !rel || rel === '..' || rel.startsWith(`..${sep}`)) {
86
+ throw new Error(`Install plan operation is outside its target: ${absolutePath}`)
87
+ }
88
+ return rel.split(sep).join('/')
89
+ }
90
+
91
+ function allowedExternalTuiPaths() {
92
+ const home = homedir()
93
+ const xdgConfig = process.env.XDG_CONFIG_HOME?.trim() || join(home, '.config')
94
+ return new Set([
95
+ resolve(home, '.opencode', 'tui.json'),
96
+ resolve(xdgConfig, 'opencode', 'tui.json'),
97
+ ])
98
+ }
99
+
100
+ function assertSafeExternalDestination(path) {
101
+ const absolute = resolve(path)
102
+ if (!allowedExternalTuiPaths().has(absolute)) {
103
+ throw new Error(`Install plan external operation is not an allowed TUI config: ${absolute}`)
104
+ }
105
+ assertNoSymlinkAncestors(absolute)
106
+ const info = lstatOrNull(absolute)
107
+ if (info && !info.isFile()) {
108
+ throw new Error(`Install plan external destination is not a regular file: ${absolute}`)
109
+ }
110
+ return absolute
111
+ }
112
+
113
+ function operationLocation(target, absolutePath) {
114
+ const absolute = resolve(absolutePath)
115
+ if (isWithin(target, absolute)) return { path: relativePlanPath(target, absolute) }
116
+ assertSafeExternalDestination(absolute)
117
+ return { path: absolute, scope: 'user-config' }
118
+ }
119
+
120
+ function assertNoPlanPathCollision(path, { packageRoot, target, operations }) {
121
+ const destination = resolve(path)
122
+ if (destination === resolve(packageRoot)) {
123
+ throw new Error(`Plan artifact destination collides with package source: ${destination}`)
124
+ }
125
+ if (destination === resolve(target)) {
126
+ throw new Error(`Plan artifact destination collides with install target: ${destination}`)
127
+ }
128
+ for (const operation of operations) {
129
+ const operationPath =
130
+ operation.scope === 'user-config' ? resolve(operation.path) : resolve(target, operation.path)
131
+ if (isWithin(operationPath, destination) || isWithin(destination, operationPath)) {
132
+ throw new Error(`Plan artifact destination collides with planned operation: ${destination}`)
133
+ }
134
+ }
135
+ }
136
+
137
+ /** Hash package source identities without exposing paths' contents or hashes. */
138
+ export function sourceFingerprint(packageRoot, excludedPaths = []) {
139
+ const root = resolve(packageRoot)
140
+ const excluded = new Set(excludedPaths.filter(Boolean).map((path) => resolve(path)))
141
+ const records = []
142
+ const walk = (directory) => {
143
+ for (const entry of readdirSync(directory, { withFileTypes: true }).sort((a, b) =>
144
+ a.name.localeCompare(b.name),
145
+ )) {
146
+ if (directory === root && IGNORED_SOURCE_DIRS.has(entry.name)) continue
147
+ const path = join(directory, entry.name)
148
+ if (excluded.has(resolve(path))) continue
149
+ const rel = relative(root, path).split(sep).join('/')
150
+ const info = lstatSync(path)
151
+ if (info.isSymbolicLink()) records.push(`L\0${rel}\0${readlinkSync(path)}`)
152
+ else if (info.isDirectory()) walk(path)
153
+ else if (info.isFile()) records.push(`F\0${rel}\0${hash(readFileSync(path))}`)
154
+ }
155
+ }
156
+ walk(root)
157
+ return hash(records.join('\n'))
158
+ }
159
+
160
+ /** Create a versioned plan containing paths and fingerprints, never contents. */
161
+ export function createPlanArtifact({
162
+ target,
163
+ packageRoot,
164
+ packageVersion,
165
+ plannedAt,
166
+ artifactPath = null,
167
+ options,
168
+ files,
169
+ snapshots,
170
+ }) {
171
+ const resolvedTarget = resolve(target)
172
+ const snapshotByPath = new Map(snapshots.map((item) => [resolve(item.path), item]))
173
+ const operations = files.map((file) => {
174
+ const location = operationLocation(resolvedTarget, file.path)
175
+ const path = location.path
176
+ if (location.scope) assertSafeExternalDestination(path)
177
+ else assertSafeDestination(resolvedTarget, path)
178
+ const before = snapshotByPath.get(resolve(file.path))
179
+ if (!before) throw new Error(`Missing destination snapshot for planned path: ${path}`)
180
+ return {
181
+ ...location,
182
+ action: file.action,
183
+ bytes: file.bytes,
184
+ outputFingerprint: file.outputFingerprint ?? null,
185
+ before: {
186
+ exists: before.exists,
187
+ sha256: before.sha256,
188
+ size: before.size,
189
+ mode: before.mode,
190
+ },
191
+ }
192
+ })
193
+ if (artifactPath) {
194
+ assertNoPlanPathCollision(artifactPath, {
195
+ packageRoot,
196
+ target: resolvedTarget,
197
+ operations,
198
+ })
199
+ }
200
+ const targetInfo = lstatOrNull(resolvedTarget)
201
+ const targetRealpath = targetInfo?.isDirectory() ? realpathSync(resolvedTarget) : null
202
+ return {
203
+ schema_version: INSTALL_PLAN_SCHEMA_VERSION,
204
+ planned_at: plannedAt,
205
+ artifact_path: artifactPath ? resolve(artifactPath) : null,
206
+ package: {
207
+ root: resolve(packageRoot),
208
+ realpath: realpathSync(packageRoot),
209
+ version: packageVersion,
210
+ source_fingerprint: sourceFingerprint(packageRoot, [artifactPath]),
211
+ },
212
+ target: { path: resolvedTarget, realpath: targetRealpath },
213
+ options: structuredClone(options),
214
+ operations,
215
+ }
216
+ }
217
+
218
+ /** Validate the closed artifact shape and every destination path. */
219
+ export function validatePlanArtifact(artifact) {
220
+ if (
221
+ !artifact ||
222
+ typeof artifact !== 'object' ||
223
+ artifact.schema_version !== INSTALL_PLAN_SCHEMA_VERSION
224
+ ) {
225
+ throw new Error('Unsupported or invalid install-plan artifact version')
226
+ }
227
+ if (
228
+ !artifact.package ||
229
+ typeof artifact.package.root !== 'string' ||
230
+ typeof artifact.package.realpath !== 'string' ||
231
+ typeof artifact.package.version !== 'string' ||
232
+ !/^[a-f0-9]{64}$/.test(artifact.package.source_fingerprint)
233
+ )
234
+ throw new Error('Invalid install-plan package identity')
235
+ if (
236
+ typeof artifact.planned_at !== 'string' ||
237
+ !Number.isFinite(Date.parse(artifact.planned_at))
238
+ ) {
239
+ throw new Error('Invalid install-plan timestamp')
240
+ }
241
+ if (
242
+ artifact.artifact_path !== null &&
243
+ (typeof artifact.artifact_path !== 'string' || !isAbsolute(artifact.artifact_path))
244
+ ) {
245
+ throw new Error('Invalid install-plan artifact identity')
246
+ }
247
+ if (
248
+ !artifact.target ||
249
+ typeof artifact.target.path !== 'string' ||
250
+ !isAbsolute(artifact.target.path)
251
+ ) {
252
+ throw new Error('Invalid install-plan target identity')
253
+ }
254
+ if (artifact.target.realpath !== null && typeof artifact.target.realpath !== 'string') {
255
+ throw new Error('Invalid install-plan target realpath')
256
+ }
257
+ if (
258
+ !artifact.options ||
259
+ typeof artifact.options !== 'object' ||
260
+ Array.isArray(artifact.options)
261
+ ) {
262
+ throw new Error('Invalid install-plan options')
263
+ }
264
+ if (!Array.isArray(artifact.operations)) throw new Error('Invalid install-plan operations')
265
+ const seen = new Set()
266
+ for (const operation of artifact.operations) {
267
+ if (
268
+ !operation ||
269
+ typeof operation.path !== 'string' ||
270
+ !operation.path ||
271
+ (operation.scope === undefined &&
272
+ (operation.path.split('/').some((part) => part === '..' || part === '') ||
273
+ isAbsolute(operation.path))) ||
274
+ (operation.scope !== undefined &&
275
+ (operation.scope !== 'user-config' ||
276
+ !isAbsolute(operation.path) ||
277
+ !allowedExternalTuiPaths().has(resolve(operation.path)))) ||
278
+ !ACTIONS.has(operation.action) ||
279
+ !Number.isSafeInteger(operation.bytes) ||
280
+ operation.bytes < 0 ||
281
+ (operation.outputFingerprint !== null && !/^[a-f0-9]{64}$/.test(operation.outputFingerprint))
282
+ )
283
+ throw new Error('Invalid install-plan operation path or metadata')
284
+ if (operation.scope === 'user-config') assertSafeExternalDestination(operation.path)
285
+ if (seen.has(operation.path)) throw new Error(`Duplicate install-plan path: ${operation.path}`)
286
+ seen.add(operation.path)
287
+ const before = operation.before
288
+ if (
289
+ !before ||
290
+ typeof before.exists !== 'boolean' ||
291
+ (before.sha256 !== null && !/^[a-f0-9]{64}$/.test(before.sha256)) ||
292
+ !Number.isSafeInteger(before.size) ||
293
+ before.size < 0 ||
294
+ (before.mode !== null && !Number.isSafeInteger(before.mode))
295
+ )
296
+ throw new Error(`Invalid destination snapshot for ${operation.path}`)
297
+ }
298
+ return artifact
299
+ }
300
+
301
+ function assertNoSymlinkAncestors(path) {
302
+ const absolute = resolve(path)
303
+ const parts = absolute.split(sep).filter(Boolean)
304
+ let current = absolute.startsWith(sep) ? sep : ''
305
+ for (const part of parts) {
306
+ current = current ? join(current, part) : part
307
+ const info = lstatOrNull(current)
308
+ if (info?.isSymbolicLink()) throw new Error(`Plan artifact path contains a symlink: ${current}`)
309
+ }
310
+ }
311
+
312
+ function openPinnedDirectory(path, { create = false } = {}) {
313
+ let descriptorRoot
314
+ if (process.platform === 'linux') descriptorRoot = '/proc/self/fd'
315
+ else if (['darwin', 'freebsd', 'openbsd', 'netbsd'].includes(process.platform))
316
+ descriptorRoot = '/dev/fd'
317
+ else {
318
+ throw new Error(`Cannot safely save a plan artifact on ${process.platform}`)
319
+ }
320
+
321
+ const absolute = resolve(path)
322
+ const root = parse(absolute).root
323
+ const directoryFlags = constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW
324
+ let directoryFd = openSync(root, directoryFlags)
325
+ try {
326
+ const descriptorDirectory = join(descriptorRoot, String(directoryFd))
327
+ const probeFd = openSync(descriptorDirectory, constants.O_RDONLY | constants.O_DIRECTORY)
328
+ closeSync(probeFd)
329
+
330
+ for (const segment of relative(root, absolute).split(sep).filter(Boolean)) {
331
+ const childPath = join(join(descriptorRoot, String(directoryFd)), segment)
332
+ let info = lstatOrNull(childPath)
333
+ if (info?.isSymbolicLink()) {
334
+ throw new Error(`Plan artifact path contains a symlink: ${join(path, segment)}`)
335
+ }
336
+ if (!info && create) {
337
+ try {
338
+ mkdirSync(childPath, { mode: 0o700 })
339
+ } catch (error) {
340
+ if (error?.code !== 'EEXIST') throw error
341
+ }
342
+ info = lstatOrNull(childPath)
343
+ }
344
+ if (info && !info.isDirectory()) {
345
+ throw new Error(`Plan artifact parent is not a directory: ${path}`)
346
+ }
347
+ if (!info) throw new Error(`Plan artifact parent does not exist: ${path}`)
348
+
349
+ const childFd = openSync(childPath, directoryFlags)
350
+ if (!fstatSync(childFd).isDirectory()) {
351
+ closeSync(childFd)
352
+ throw new Error(`Plan artifact parent is not a directory: ${path}`)
353
+ }
354
+ closeSync(directoryFd)
355
+ directoryFd = childFd
356
+ }
357
+
358
+ return {
359
+ fd: directoryFd,
360
+ path: join(descriptorRoot, String(directoryFd)),
361
+ }
362
+ } catch (error) {
363
+ try {
364
+ closeSync(directoryFd)
365
+ } catch {}
366
+ throw error
367
+ }
368
+ }
369
+
370
+ function assertPinnedDirectoryMatches(fd, path) {
371
+ assertNoSymlinkAncestors(path)
372
+ const pathInfo = lstatOrNull(path)
373
+ const fdInfo = fstatSync(fd)
374
+ if (!pathInfo?.isDirectory() || pathInfo.dev !== fdInfo.dev || pathInfo.ino !== fdInfo.ino) {
375
+ throw new Error(`Plan artifact parent changed during write: ${path}`)
376
+ }
377
+ }
378
+
379
+ /** Atomically write a plan file with restrictive permissions. */
380
+ export function savePlanArtifact(path, artifact) {
381
+ validatePlanArtifact(artifact)
382
+ const destination = resolve(path)
383
+ assertNoPlanPathCollision(destination, {
384
+ packageRoot: artifact.package.root,
385
+ target: artifact.target.path,
386
+ operations: artifact.operations,
387
+ })
388
+ assertNoSymlinkAncestors(destination)
389
+ const parent = dirname(destination)
390
+ const pinnedParent = openPinnedDirectory(parent, { create: true })
391
+ const destinationName = basename(destination)
392
+ const pinnedDestination = join(pinnedParent.path, destinationName)
393
+ const temporary = join(
394
+ pinnedParent.path,
395
+ `${destinationName}.tmp-${process.pid}-${randomBytes(12).toString('hex')}`,
396
+ )
397
+ const payload = `${JSON.stringify(artifact, null, 2)}\n`
398
+ let descriptor = null
399
+ let temporaryCreated = false
400
+ let destinationCreated = false
401
+ let temporaryInfo = null
402
+ try {
403
+ assertPinnedDirectoryMatches(pinnedParent.fd, parent)
404
+ const existing = lstatOrNull(pinnedDestination)
405
+ if (existing?.isSymbolicLink()) {
406
+ throw new Error(`Plan artifact destination is a symlink: ${destination}`)
407
+ }
408
+ if (existing && !existing.isFile()) {
409
+ throw new Error(`Plan artifact destination is not a regular file: ${destination}`)
410
+ }
411
+ if (existing) throw new Error(`Plan artifact destination already exists: ${destination}`)
412
+
413
+ descriptor = openSync(temporary, 'wx', 0o600)
414
+ temporaryCreated = true
415
+ writeFileSync(descriptor, payload)
416
+ fchmodSync(descriptor, 0o600)
417
+ temporaryInfo = fstatSync(descriptor)
418
+ closeSync(descriptor)
419
+ descriptor = null
420
+ assertPinnedDirectoryMatches(pinnedParent.fd, parent)
421
+ // Hard-link creation is atomic and fails with EEXIST instead of replacing
422
+ // a destination that appeared after the anchored validation.
423
+ linkSync(temporary, pinnedDestination)
424
+ destinationCreated = true
425
+ assertPinnedDirectoryMatches(pinnedParent.fd, parent)
426
+ unlinkSync(temporary)
427
+ temporaryCreated = false
428
+ } catch (error) {
429
+ if (descriptor !== null) {
430
+ try {
431
+ closeSync(descriptor)
432
+ } catch {}
433
+ }
434
+ if (destinationCreated) {
435
+ try {
436
+ const destinationInfo = lstatOrNull(pinnedDestination)
437
+ if (
438
+ destinationInfo?.isFile() &&
439
+ destinationInfo.dev === temporaryInfo?.dev &&
440
+ destinationInfo.ino === temporaryInfo?.ino
441
+ ) {
442
+ unlinkSync(pinnedDestination)
443
+ }
444
+ } catch {}
445
+ }
446
+ try {
447
+ if (temporaryCreated) unlinkSync(temporary)
448
+ } catch {}
449
+ throw error
450
+ } finally {
451
+ closeSync(pinnedParent.fd)
452
+ }
453
+ }
454
+
455
+ /** Read and validate a plan artifact without following a symlink. */
456
+ export function loadPlanArtifact(path) {
457
+ const absolute = resolve(path)
458
+ assertNoSymlinkAncestors(absolute)
459
+ const info = lstatOrNull(absolute)
460
+ if (info?.isSymbolicLink()) throw new Error(`Plan artifact is a symlink: ${absolute}`)
461
+ if (!info?.isFile()) throw new Error(`Plan artifact is not a regular file: ${absolute}`)
462
+ if (info.size > 10 * 1024 * 1024)
463
+ throw new Error('Install-plan artifact exceeds the 10 MiB limit')
464
+ let artifact
465
+ try {
466
+ artifact = JSON.parse(readFileSync(absolute, 'utf8'))
467
+ } catch {
468
+ throw new Error('Install-plan artifact is not valid JSON')
469
+ }
470
+ return validatePlanArtifact(artifact)
471
+ }
472
+
473
+ /** Compare current inputs and destinations against a saved reviewed plan. */
474
+ export function findPlanDrift(artifact, { target, packageRoot, packageVersion, files, snapshots }) {
475
+ validatePlanArtifact(artifact)
476
+ const root = resolve(target)
477
+ const reasons = []
478
+ const targetRealpath = lstatOrNull(root)?.isDirectory() ? realpathSync(root) : null
479
+ if (root !== artifact.target.path || targetRealpath !== artifact.target.realpath)
480
+ reasons.push('target')
481
+ if (
482
+ resolve(packageRoot) !== artifact.package.root ||
483
+ realpathSync(packageRoot) !== artifact.package.realpath ||
484
+ packageVersion !== artifact.package.version ||
485
+ sourceFingerprint(packageRoot, [artifact.artifact_path]) !== artifact.package.source_fingerprint
486
+ )
487
+ reasons.push('source')
488
+
489
+ const currentSnapshots = new Map(snapshots.map((item) => [resolve(item.path), item]))
490
+ for (const operation of artifact.operations) {
491
+ const absolute =
492
+ operation.scope === 'user-config'
493
+ ? assertSafeExternalDestination(operation.path)
494
+ : assertSafeDestination(root, operation.path)
495
+ const current = currentSnapshots.get(absolute)
496
+ if (
497
+ !current ||
498
+ JSON.stringify(current) !== JSON.stringify({ path: absolute, ...operation.before })
499
+ ) {
500
+ reasons.push(operation.path)
501
+ }
502
+ }
503
+ const generated = files.map((file) => ({
504
+ ...operationLocation(root, file.path),
505
+ action: file.action,
506
+ bytes: file.bytes,
507
+ outputFingerprint: file.outputFingerprint ?? null,
508
+ }))
509
+ const reviewed = artifact.operations.map(({ path, scope, action, bytes, outputFingerprint }) => ({
510
+ ...(scope === undefined ? { path } : { path, scope }),
511
+ action,
512
+ bytes,
513
+ outputFingerprint,
514
+ }))
515
+ if (JSON.stringify(generated) !== JSON.stringify(reviewed)) reasons.push('operations')
516
+ return [...new Set(reasons)]
517
+ }