@vzn/vx 0.0.192 → 0.0.193
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.193",
|
|
4
4
|
"description": "A fast, correct task runner and build cache for JavaScript monorepos.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -17,10 +17,10 @@
|
|
|
17
17
|
"node": ">=18"
|
|
18
18
|
},
|
|
19
19
|
"optionalDependencies": {
|
|
20
|
-
"@vzn/vx-linux-x64": "0.0.
|
|
21
|
-
"@vzn/vx-linux-arm64": "0.0.
|
|
22
|
-
"@vzn/vx-darwin-x64": "0.0.
|
|
23
|
-
"@vzn/vx-darwin-arm64": "0.0.
|
|
20
|
+
"@vzn/vx-linux-x64": "0.0.193",
|
|
21
|
+
"@vzn/vx-linux-arm64": "0.0.193",
|
|
22
|
+
"@vzn/vx-darwin-x64": "0.0.193",
|
|
23
|
+
"@vzn/vx-darwin-arm64": "0.0.193"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
26
|
"@anthropic-ai/sandbox-runtime": "^0.0.76"
|
package/src/cache/index.ts
CHANGED
package/src/cache/key-fold.ts
CHANGED
|
@@ -89,6 +89,22 @@ import type { CacheKeyInput } from './layer.js'
|
|
|
89
89
|
// after the sibling changes (probed: the old output replayed).
|
|
90
90
|
export const CACHE_VERSION = 'vx-cache-v36'
|
|
91
91
|
|
|
92
|
+
/**
|
|
93
|
+
* The digest a key folds for an input gone between its enumeration and its
|
|
94
|
+
* hash: an upstream task that deletes a file its dependant's globs matched
|
|
95
|
+
* (a `clean` ahead of a `build`) failed the dependant as an internal error on
|
|
96
|
+
* every run (A-55). Not an identity any file has, so a run that finds the
|
|
97
|
+
* file keys apart from one that does not.
|
|
98
|
+
*/
|
|
99
|
+
export const ABSENT_INPUT = 'absent'
|
|
100
|
+
|
|
101
|
+
/** `ABSENT_INPUT` for a file that is not there to read; any other error rethrown. */
|
|
102
|
+
export function absentOr(err: unknown): string {
|
|
103
|
+
const code = (err as NodeJS.ErrnoException | null)?.code
|
|
104
|
+
if (code === 'ENOENT' || code === 'ENOTDIR') return ABSENT_INPUT
|
|
105
|
+
throw err
|
|
106
|
+
}
|
|
107
|
+
|
|
92
108
|
/**
|
|
93
109
|
* Fold one task's key inputs into its 16-hex cache key. `hashFile` answers
|
|
94
110
|
* for an input file the caller's `fileHashes` map does not cover (the git
|
|
@@ -226,7 +242,9 @@ export async function foldKey(
|
|
|
226
242
|
}
|
|
227
243
|
if (out.length === sortedInputs.length) fileHashes = out
|
|
228
244
|
}
|
|
229
|
-
fileHashes ??= await Promise.all(
|
|
245
|
+
fileHashes ??= await Promise.all(
|
|
246
|
+
sortedInputs.map((f) => provided?.get(f) ?? hashFile(f).catch(absentOr)),
|
|
247
|
+
)
|
|
230
248
|
for (let i = 0; i < sortedInputs.length; i++) {
|
|
231
249
|
const file = sortedInputs[i]!
|
|
232
250
|
const rel = relOf(file)
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// The sandbox runtime's mandatory-deny scan, scoped to what a task can
|
|
2
|
+
// write (B-40). SRT walks `process.cwd()` (the workspace root) with
|
|
3
|
+
// `rg --max-depth 3` on every wrap and keeps a hit only when it lies in
|
|
4
|
+
// an allowed write path: anything else is already read-only under
|
|
5
|
+
// `--ro-bind / /`. A deny is the hit or one of its ancestors, so every
|
|
6
|
+
// hit that counts lies under a write path, and walking the write paths
|
|
7
|
+
// finds them all. On 1,090 packages the whole-root walk was half of a
|
|
8
|
+
// sandboxed run's wall time (25.5 s → 14.0 s without it). SRT keeps its
|
|
9
|
+
// own scan at depth 1 for the root's entries; this supplies the rest,
|
|
10
|
+
// per task and never from a memo (a refusal is decided by it).
|
|
11
|
+
//
|
|
12
|
+
// It is a superset of rg's hits, never a subset: it does not read
|
|
13
|
+
// `.gitignore`, walks into `node_modules`, and counts a symlink by its
|
|
14
|
+
// name. Each can only add a deny where rg would have skipped the path.
|
|
15
|
+
|
|
16
|
+
import { lstatSync, readdirSync } from 'node:fs'
|
|
17
|
+
import { homedir } from 'node:os'
|
|
18
|
+
import path from 'node:path'
|
|
19
|
+
import { UserError } from '../util/index.js'
|
|
20
|
+
import { atOrUnder, MOUNT_WILDCARDS } from './sandbox-paths.js'
|
|
21
|
+
|
|
22
|
+
/** SRT's `rg --max-depth` for the scan (`DEFAULT_MANDATORY_DENY_SEARCH_DEPTH`). */
|
|
23
|
+
const SEARCH_DEPTH = 3
|
|
24
|
+
|
|
25
|
+
/** SRT's `DANGEROUS_FILES`: matched by name, case-insensitively, at any depth. */
|
|
26
|
+
const DANGEROUS_FILES = new Set([
|
|
27
|
+
'.gitconfig',
|
|
28
|
+
'.gitmodules',
|
|
29
|
+
'.bashrc',
|
|
30
|
+
'.bash_profile',
|
|
31
|
+
'.zshrc',
|
|
32
|
+
'.zprofile',
|
|
33
|
+
'.profile',
|
|
34
|
+
'.ripgreprc',
|
|
35
|
+
'.mcp.json',
|
|
36
|
+
])
|
|
37
|
+
|
|
38
|
+
/** SRT's `getDangerousDirectories()`: a hit is any file below one. */
|
|
39
|
+
const DANGEROUS_DIRS = ['.vscode', '.idea', '.claude/commands', '.claude/agents']
|
|
40
|
+
|
|
41
|
+
/** The write paths SRT adds to every wrap (`getDefaultWritePaths`); `/dev` never holds a hit. */
|
|
42
|
+
export function srtDefaultWritePaths(): string[] {
|
|
43
|
+
const home = homedir()
|
|
44
|
+
return [
|
|
45
|
+
'/tmp/claude',
|
|
46
|
+
'/private/tmp/claude',
|
|
47
|
+
path.join(home, '.npm/_logs'),
|
|
48
|
+
path.join(home, '.claude/debug'),
|
|
49
|
+
]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const lower = (s: string): string => s.toLowerCase()
|
|
53
|
+
|
|
54
|
+
/** Whether `rel` (from the scan root, `/`-separated) is one of rg's hits for SRT's patterns. */
|
|
55
|
+
function isHit(rel: string): boolean {
|
|
56
|
+
const segs = rel.split('/').map(lower)
|
|
57
|
+
if (DANGEROUS_FILES.has(segs[segs.length - 1]!)) return true
|
|
58
|
+
const inside = (dir: string): boolean => {
|
|
59
|
+
const d = dir.split('/')
|
|
60
|
+
for (let i = 0; i + d.length < segs.length; i++) {
|
|
61
|
+
if (d.every((s, j) => segs[i + j] === s)) return true
|
|
62
|
+
}
|
|
63
|
+
return false
|
|
64
|
+
}
|
|
65
|
+
if (DANGEROUS_DIRS.some(inside) || inside('.git/hooks')) return true
|
|
66
|
+
// `**/.git/config`, and only while `allowGitConfig` is off: vx never
|
|
67
|
+
// turns it on in the run's config, which is the one SRT's scan reads.
|
|
68
|
+
return segs.length >= 2 && segs[segs.length - 2] === '.git' && segs[segs.length - 1] === 'config'
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The deny path SRT derives from a hit (`linuxGetMandatoryDenyPaths`), or none. */
|
|
72
|
+
function denyOf(cwd: string, rel: string): string | undefined {
|
|
73
|
+
const absolute = path.resolve(cwd, rel)
|
|
74
|
+
const segments = absolute.split(path.sep)
|
|
75
|
+
// Two-segment names never equal one segment: their hits fall through to
|
|
76
|
+
// the file itself, as they do in SRT.
|
|
77
|
+
for (const dir of [...DANGEROUS_DIRS, '.git']) {
|
|
78
|
+
const i = segments.findIndex((s) => lower(s) === lower(dir))
|
|
79
|
+
if (i === -1) continue
|
|
80
|
+
if (dir !== '.git') return segments.slice(0, i + 1).join(path.sep)
|
|
81
|
+
const gitDir = segments.slice(0, i + 1).join(path.sep)
|
|
82
|
+
if (rel.includes('.git/hooks')) return path.join(gitDir, 'hooks')
|
|
83
|
+
if (rel.includes('.git/config')) return path.join(gitDir, 'config')
|
|
84
|
+
return undefined
|
|
85
|
+
}
|
|
86
|
+
return absolute
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The mandatory write denies for a task whose write grants are
|
|
91
|
+
* `writePaths` (absolute), as SRT's whole-root scan of `cwd` would find
|
|
92
|
+
* them within those grants. Throws when a deny could not reach bwrap: a
|
|
93
|
+
* path with a glob character is dropped from `denyWrite` on Linux, and
|
|
94
|
+
* the write it guards must not be left open.
|
|
95
|
+
*/
|
|
96
|
+
export function scopedMandatoryDenies(cwd: string, writePaths: readonly string[]): string[] {
|
|
97
|
+
const roots = new Set<string>()
|
|
98
|
+
for (const raw of writePaths) {
|
|
99
|
+
const w = raw.replace(/\/+$/, '') || '/'
|
|
100
|
+
if (atOrUnder(cwd, w)) roots.add(cwd)
|
|
101
|
+
else if (atOrUnder(w, cwd)) roots.add(w)
|
|
102
|
+
}
|
|
103
|
+
const denies = new Set<string>()
|
|
104
|
+
const visit = (abs: string, rel: string, depth: number): void => {
|
|
105
|
+
let entries
|
|
106
|
+
try {
|
|
107
|
+
entries = readdirSync(abs, { withFileTypes: true })
|
|
108
|
+
} catch {
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
for (const e of entries) {
|
|
112
|
+
const childRel = rel === '' ? e.name : `${rel}/${e.name}`
|
|
113
|
+
if (e.isDirectory()) {
|
|
114
|
+
if (depth + 1 < SEARCH_DEPTH) visit(path.join(abs, e.name), childRel, depth + 1)
|
|
115
|
+
} else if (isHit(childRel)) {
|
|
116
|
+
const d = denyOf(cwd, childRel)
|
|
117
|
+
if (d !== undefined) denies.add(d)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
for (const root of roots) {
|
|
122
|
+
const rel = path.relative(cwd, root).split(path.sep).join('/')
|
|
123
|
+
const depth = rel === '' ? 0 : rel.split('/').length
|
|
124
|
+
if (depth > SEARCH_DEPTH) continue
|
|
125
|
+
let dir: boolean
|
|
126
|
+
try {
|
|
127
|
+
dir = lstatSync(root).isDirectory()
|
|
128
|
+
} catch {
|
|
129
|
+
continue
|
|
130
|
+
}
|
|
131
|
+
if (dir) {
|
|
132
|
+
if (depth < SEARCH_DEPTH) visit(root, rel, depth)
|
|
133
|
+
} else if (depth > 0 && isHit(rel)) {
|
|
134
|
+
const d = denyOf(cwd, rel)
|
|
135
|
+
if (d !== undefined) denies.add(d)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
for (const d of denies) {
|
|
139
|
+
// SRT's `containsGlobChars` is this set: such a `denyWrite` entry is dropped on Linux.
|
|
140
|
+
if (MOUNT_WILDCARDS.test(d)) {
|
|
141
|
+
throw new UserError(
|
|
142
|
+
`the sandbox must keep ${d} read-only, and a path with a glob character (* ? [ ]) cannot reach it — rename the directory`,
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return [...denies]
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Whether the scan can be scoped here: a root with a glob character loses every supplied deny. */
|
|
150
|
+
export function canScopeDenyScan(cwd: string): boolean {
|
|
151
|
+
return !MOUNT_WILDCARDS.test(cwd)
|
|
152
|
+
}
|
|
@@ -71,6 +71,11 @@ import {
|
|
|
71
71
|
toRealPath,
|
|
72
72
|
unique,
|
|
73
73
|
} from './sandbox-paths.js'
|
|
74
|
+
import {
|
|
75
|
+
canScopeDenyScan,
|
|
76
|
+
scopedMandatoryDenies,
|
|
77
|
+
srtDefaultWritePaths,
|
|
78
|
+
} from './sandbox-deny-scan.js'
|
|
74
79
|
import { parseStraceViolations, refusedWrites, reportableViolations } from './sandbox-violations.js'
|
|
75
80
|
import {
|
|
76
81
|
closeSignalChannel,
|
|
@@ -469,6 +474,9 @@ function bundledJavaAgent(): { javaAgentJarPath?: string } {
|
|
|
469
474
|
return javaAgent
|
|
470
475
|
}
|
|
471
476
|
|
|
477
|
+
/** Whether this run's SRT scans at depth 1 and vx supplies the task-scoped denies (B-40). */
|
|
478
|
+
let scopedDenyScan = false
|
|
479
|
+
|
|
472
480
|
function linuxToolPaths(): { bwrapPath?: string; socatPath?: string } {
|
|
473
481
|
if (process.platform !== 'linux') return {}
|
|
474
482
|
const paths: { bwrapPath?: string; socatPath?: string } = {}
|
|
@@ -540,6 +548,7 @@ export async function initSandbox(opts?: {
|
|
|
540
548
|
// Before SRT starts, so the very first task already has one.
|
|
541
549
|
await mkdir(sandboxTmpdir(), { recursive: true })
|
|
542
550
|
const { SandboxManager } = await loadSrt()
|
|
551
|
+
scopedDenyScan = process.platform === 'linux' && canScopeDenyScan(process.cwd())
|
|
543
552
|
const config: Parameters<typeof SandboxManager.initialize>[0] = {
|
|
544
553
|
network: {
|
|
545
554
|
allowedDomains: [...(opts?.allowedDomains ?? [])],
|
|
@@ -549,6 +558,9 @@ export async function initSandbox(opts?: {
|
|
|
549
558
|
filesystem: { denyRead: [], allowWrite: [], denyWrite: [] },
|
|
550
559
|
ignoreViolations: DEFAULT_IGNORE_VIOLATIONS,
|
|
551
560
|
...linuxToolPaths(),
|
|
561
|
+
// SRT's own scan walks only the root's entries; `wrapSandboxedCommand`
|
|
562
|
+
// walks each task's write grants for the rest (B-40).
|
|
563
|
+
...(scopedDenyScan ? { mandatoryDenySearchDepth: 1 } : {}),
|
|
552
564
|
...bundledJavaAgent(),
|
|
553
565
|
}
|
|
554
566
|
if (!srtUp) await unlinkStaleMuxSockets()
|
|
@@ -928,6 +940,14 @@ export async function wrapSandboxedCommand(
|
|
|
928
940
|
const customConfig = buildCustomConfig(args, baselines)
|
|
929
941
|
customConfig!.filesystem!.denyRead!.push(toRealPath(taskTmpRoot()))
|
|
930
942
|
customConfig!.filesystem!.allowWrite!.push(toRealPath(tmp))
|
|
943
|
+
if (scopedDenyScan) {
|
|
944
|
+
customConfig!.filesystem!.denyWrite!.push(
|
|
945
|
+
...scopedMandatoryDenies(process.cwd(), [
|
|
946
|
+
...srtDefaultWritePaths(),
|
|
947
|
+
...customConfig!.filesystem!.allowWrite!,
|
|
948
|
+
]),
|
|
949
|
+
)
|
|
950
|
+
}
|
|
931
951
|
// Seatbelt re-allows a read inside a denied region only by name; on Linux
|
|
932
952
|
// a read grant over a write path would remount it read-only.
|
|
933
953
|
if (process.platform === 'darwin') customConfig!.filesystem!.allowRead!.push(toRealPath(tmp))
|
|
@@ -3,6 +3,8 @@ import path from 'node:path'
|
|
|
3
3
|
import type { TaskConfig, CacheConfig } from '../config.js'
|
|
4
4
|
import type { ProjectFilesCache, WorkspaceFilesCache } from '../cache/index.js'
|
|
5
5
|
import {
|
|
6
|
+
ABSENT_INPUT,
|
|
7
|
+
absentOr,
|
|
6
8
|
type CacheKeyInput,
|
|
7
9
|
type CacheLayer,
|
|
8
10
|
FILE_HASH_RACY_MS,
|
|
@@ -138,12 +140,14 @@ export async function describeTaskInputs(
|
|
|
138
140
|
const hash = await args.cache.key(input)
|
|
139
141
|
const sorted = [...input.inputFiles].sort()
|
|
140
142
|
const digests = await Promise.all(
|
|
141
|
-
sorted.map((f) => input.fileHashes?.get(f) ?? args.cache.hashFile(f)),
|
|
143
|
+
sorted.map((f) => input.fileHashes?.get(f) ?? args.cache.hashFile(f).catch(absentOr)),
|
|
144
|
+
)
|
|
145
|
+
// A file gone since the enumeration is no input: the task will not read it.
|
|
146
|
+
const files: InputFile[] = sorted.flatMap((f, i) =>
|
|
147
|
+
digests[i] === ABSENT_INPUT
|
|
148
|
+
? []
|
|
149
|
+
: [{ path: relPosix(input.workspaceRoot, f), digest: digests[i]! }],
|
|
142
150
|
)
|
|
143
|
-
const files: InputFile[] = sorted.map((f, i) => ({
|
|
144
|
-
path: relPosix(input.workspaceRoot, f),
|
|
145
|
-
digest: digests[i]!,
|
|
146
|
-
}))
|
|
147
151
|
// The package.json digest is a per-run memo that may predate this
|
|
148
152
|
// describe, so it is dated from the enumeration, the earliest a run
|
|
149
153
|
// learns anything.
|
|
@@ -221,8 +225,12 @@ export async function movedInput(
|
|
|
221
225
|
try {
|
|
222
226
|
ctimeMs = lstatSync(f.path).ctimeMs
|
|
223
227
|
} catch {
|
|
228
|
+
// Gone, as the key folded it (`ABSENT_INPUT`): unmoved.
|
|
229
|
+
if (f.digest === ABSENT_INPUT) continue
|
|
224
230
|
return f.path
|
|
225
231
|
}
|
|
232
|
+
// Back since the key folded it gone.
|
|
233
|
+
if (f.digest === ABSENT_INPUT) return f.path
|
|
226
234
|
// A whole-second stamp may stand for any write in its second (two for
|
|
227
235
|
// an even one, FAT's tick), one during the command included
|
|
228
236
|
// (`racyWindowMs`).
|
|
@@ -386,6 +386,7 @@ export async function loadProjectConfigs(
|
|
|
386
386
|
evaluated?: readonly [string, string]
|
|
387
387
|
closure?: readonly [string, readonly string[]]
|
|
388
388
|
}
|
|
389
|
+
const builtins = builtinSnapshot()
|
|
389
390
|
const loadOne = async (entry: (typeof prepared)[number]): Promise<Loaded> => {
|
|
390
391
|
const { configPath, cacheKey } = entry
|
|
391
392
|
// A fast key that missed: the closure is stale or the file changed.
|
|
@@ -424,6 +425,10 @@ export async function loadProjectConfigs(
|
|
|
424
425
|
throw configLoadError(err, configPath, 'Project') ?? err
|
|
425
426
|
})
|
|
426
427
|
: await loadDefaultExport(configPath, 'Project', bytes!)
|
|
428
|
+
// Before anything reads through them: a replaced `Array.prototype.includes`
|
|
429
|
+
// turned the JSON-data walk's own check into "a cyclic reference".
|
|
430
|
+
const changed = repeat ? [] : restoreBuiltins(builtins)
|
|
431
|
+
if (changed.length > 0) throw builtinsChanged(changed, configPath)
|
|
427
432
|
assertDefaultObject(mod, 'Project', configPath)
|
|
428
433
|
// Validation runs HERE, on whichever object we ended up with, so a
|
|
429
434
|
// malformed config reports the identical UserError whether it was
|
|
@@ -480,7 +485,9 @@ export async function loadProjectConfigs(
|
|
|
480
485
|
if (r.closure !== undefined) learnedClosures.push(r.closure)
|
|
481
486
|
}
|
|
482
487
|
}
|
|
488
|
+
const changed = restoreBuiltins(builtins)
|
|
483
489
|
if (first !== undefined) throw first.failed
|
|
490
|
+
if (changed.length > 0) throw builtinsChanged(changed)
|
|
484
491
|
return results.map((r) => (r as Loaded).config)
|
|
485
492
|
} finally {
|
|
486
493
|
endRound()
|
|
@@ -497,6 +504,70 @@ export async function loadProjectConfigs(
|
|
|
497
504
|
}
|
|
498
505
|
}
|
|
499
506
|
|
|
507
|
+
/**
|
|
508
|
+
* The built-in prototypes a config's object is read through. A config that
|
|
509
|
+
* set `Object.prototype.exec` gave every other project's task that
|
|
510
|
+
* command, and the key, which folds each config's own JSON, never saw it:
|
|
511
|
+
* a hit replayed under a key that did not name what ran (D-74). A first
|
|
512
|
+
* load runs in this process, so the round compares them before and after,
|
|
513
|
+
* puts back what changed (a failed load too) and refuses.
|
|
514
|
+
*/
|
|
515
|
+
const WATCHED_BUILTINS: ReadonlyArray<readonly [string, object]> = [
|
|
516
|
+
['Object.prototype', Object.prototype],
|
|
517
|
+
['Array.prototype', Array.prototype],
|
|
518
|
+
]
|
|
519
|
+
|
|
520
|
+
type BuiltinSnapshot = ReadonlyArray<ReadonlyMap<PropertyKey, PropertyDescriptor>>
|
|
521
|
+
|
|
522
|
+
function builtinSnapshot(): BuiltinSnapshot {
|
|
523
|
+
return WATCHED_BUILTINS.map(
|
|
524
|
+
([, proto]) =>
|
|
525
|
+
new Map(Reflect.ownKeys(proto).map((k) => [k, Object.getOwnPropertyDescriptor(proto, k)!])),
|
|
526
|
+
)
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/** Loads run together, so the config named is the one whose load saw the change. */
|
|
530
|
+
function builtinsChanged(changed: readonly string[], configPath?: string): UserError {
|
|
531
|
+
const who = configPath === undefined ? 'a project config' : configPath
|
|
532
|
+
return new UserError(
|
|
533
|
+
`${who} changed ${changed.join(', ')} while it was evaluated — a config must not change the built-ins: every other config is read through them, and the cache key does not see what they add`,
|
|
534
|
+
)
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** Puts back what changed since `before`, naming each property it put back. */
|
|
538
|
+
function restoreBuiltins(before: BuiltinSnapshot): string[] {
|
|
539
|
+
const changed: string[] = []
|
|
540
|
+
WATCHED_BUILTINS.forEach(([name, proto], i) => {
|
|
541
|
+
const was = before[i]!
|
|
542
|
+
for (const key of Reflect.ownKeys(proto)) {
|
|
543
|
+
const prior = was.get(key)
|
|
544
|
+
const now = Object.getOwnPropertyDescriptor(proto, key)!
|
|
545
|
+
if (prior !== undefined && sameDescriptor(prior, now)) continue
|
|
546
|
+
changed.push(`${name}.${String(key)}`)
|
|
547
|
+
if (prior === undefined) Reflect.deleteProperty(proto, key)
|
|
548
|
+
else Object.defineProperty(proto, key, prior)
|
|
549
|
+
}
|
|
550
|
+
for (const [key, prior] of was) {
|
|
551
|
+
if (!Object.hasOwn(proto, key)) {
|
|
552
|
+
changed.push(`${name}.${String(key)}`)
|
|
553
|
+
Object.defineProperty(proto, key, prior)
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
})
|
|
557
|
+
return changed
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function sameDescriptor(a: PropertyDescriptor, b: PropertyDescriptor): boolean {
|
|
561
|
+
return (
|
|
562
|
+
a.value === b.value &&
|
|
563
|
+
a.get === b.get &&
|
|
564
|
+
a.set === b.set &&
|
|
565
|
+
a.writable === b.writable &&
|
|
566
|
+
a.enumerable === b.enumerable &&
|
|
567
|
+
a.configurable === b.configurable
|
|
568
|
+
)
|
|
569
|
+
}
|
|
570
|
+
|
|
500
571
|
export async function loadProjectConfig(
|
|
501
572
|
configPath: string,
|
|
502
573
|
opts?: LoadProjectConfigOptions,
|