@exodus/stasis 1.0.0-alpha.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/src/config.js ADDED
@@ -0,0 +1,205 @@
1
+ import assert from 'node:assert/strict'
2
+
3
+ const VALID_SCOPE = new Set(['node_modules', 'full'])
4
+ const VALID_LOCK = new Set(['none', 'ignore', 'add', 'replace', 'frozen'])
5
+ const VALID_BUNDLE = new Set(['none', 'ignore', 'add', 'replace', 'load'])
6
+
7
+ const envDebugBool = (value) => Boolean(value && value !== '0')
8
+
9
+ const OPTION_KEYS = ['scope', 'lock', 'bundle', 'bundleFile', 'debug']
10
+
11
+ // Plugins accept the same options as Config but want to validate without constructing one,
12
+ // since constructing State has side effects. Mirror Config's per-field validation.
13
+ export function validatePluginOptions(label, options) {
14
+ const rest = { ...options }
15
+ for (const key of OPTION_KEYS) delete rest[key]
16
+ assert.equal(Object.keys(rest).length, 0, `Unknown ${label} options: ${Object.keys(rest).join(', ')}`)
17
+ const { scope, lock, bundle, bundleFile, debug } = options
18
+ if (scope !== undefined) assert.ok(VALID_SCOPE.has(scope), `Invalid scope: ${scope}`)
19
+ if (lock !== undefined) assert.ok(VALID_LOCK.has(lock), `Invalid lock: ${lock}`)
20
+ if (bundle !== undefined) assert.ok(VALID_BUNDLE.has(bundle), `Invalid bundle: ${bundle}`)
21
+ if (bundleFile !== undefined) assert.equal(typeof bundleFile, 'string', 'bundleFile must be a string')
22
+ if (debug !== undefined) assert.equal(typeof debug, 'boolean', 'debug must be a boolean')
23
+ }
24
+
25
+ // When a plugin runs against a State that already exists (preload path), the active Config
26
+ // is authoritative. Any options the plugin was given must agree with it.
27
+ export function assertOptionsMatchConfig(config, options) {
28
+ const { scope, lock, bundle, bundleFile, debug } = options
29
+ try {
30
+ if (scope !== undefined) assert.equal(config.scope, scope)
31
+ if (lock !== undefined) assert.equal(config.lock, lock)
32
+ if (bundle !== undefined) assert.equal(config.bundleMode, bundle)
33
+ if (bundleFile !== undefined) assert.equal(config.bundleFile, bundleFile)
34
+ if (debug !== undefined) assert.equal(config.debug, debug)
35
+ } catch (cause) {
36
+ throw new Error('Plugin options conflict with active stasis state', { cause })
37
+ }
38
+ }
39
+
40
+ export class Config {
41
+ #env
42
+ #scope
43
+ #lock
44
+ #bundle
45
+ #bundleFile
46
+ #debug
47
+
48
+ // Options match the CLI flags (lock/bundle/bundleFile/scope/debug). Env vars take effect at
49
+ // construction time; if both env and an option are set they must agree.
50
+ constructor(options = {}) {
51
+ const { scope, lock, bundle, bundleFile, debug, ...rest } = options
52
+ assert.equal(Object.keys(rest).length, 0, `Unknown Config options: ${Object.keys(rest).join(', ')}`)
53
+
54
+ this.#env = {
55
+ scope: process.env.EXODUS_STASIS_SCOPE || undefined,
56
+ lock: process.env.EXODUS_STASIS_LOCK || undefined,
57
+ bundle: process.env.EXODUS_STASIS_BUNDLE || undefined,
58
+ bundleFile: process.env.EXODUS_STASIS_BUNDLE_FILE || undefined,
59
+ debug: process.env.EXODUS_STASIS_DEBUG || undefined,
60
+ }
61
+
62
+ try {
63
+ if (this.#env.scope !== undefined && scope !== undefined) assert.equal(this.#env.scope, scope)
64
+ if (this.#env.lock !== undefined && lock !== undefined) assert.equal(this.#env.lock, lock)
65
+ if (this.#env.bundle !== undefined && bundle !== undefined) assert.equal(this.#env.bundle, bundle)
66
+ if (this.#env.bundleFile !== undefined && bundleFile !== undefined) {
67
+ assert.equal(this.#env.bundleFile, bundleFile)
68
+ }
69
+ if (this.#env.debug !== undefined && debug !== undefined) {
70
+ assert.equal(envDebugBool(this.#env.debug), debug)
71
+ }
72
+ } catch (cause) {
73
+ throw new Error('Config options can not override stasis env', { cause })
74
+ }
75
+
76
+ this.#scope = this.#env.scope || scope || 'full'
77
+ this.#lock = this.#env.lock || lock || 'add'
78
+ this.#bundle = this.#env.bundle || bundle || 'none'
79
+ this.#bundleFile = this.#env.bundleFile || bundleFile || undefined
80
+ this.#debug = this.#env.debug !== undefined ? envDebugBool(this.#env.debug) : (debug ?? false)
81
+
82
+ this.#checkInvariants()
83
+ }
84
+
85
+ // Per-field validation + cross-field invariants. Constructor (options path) and
86
+ // loadConfig (disk path) both call this so the rules live in one place.
87
+ #checkInvariants() {
88
+ assert.ok(VALID_SCOPE.has(this.#scope), `Invalid scope: ${this.#scope}`)
89
+ assert.ok(VALID_LOCK.has(this.#lock), `Invalid lock: ${this.#lock}`)
90
+ assert.ok(VALID_BUNDLE.has(this.#bundle), `Invalid bundle: ${this.#bundle}`)
91
+ assert.equal(typeof this.#debug, 'boolean', 'debug must be a boolean')
92
+ if (this.#bundleFile !== undefined) assert.equal(typeof this.#bundleFile, 'string', 'bundleFile must be a string')
93
+
94
+ // bundle=load needs a trust root for source bytes: frozen pins each file's sha512 in
95
+ // the lockfile and we cross-check it on load; otherwise the bundle is itself
96
+ // authoritative -- lock=none with no lockfile on disk, or lock=ignore with a lockfile
97
+ // deliberately bypassed.
98
+ // WARNING: lockfile attests only to source BYTES, not bundle metadata.
99
+ if (this.#bundle === 'load' && this.#lock !== 'frozen' && this.#lock !== 'none' && this.#lock !== 'ignore') {
100
+ throw new RangeError('bundle=load requires lock=(frozen|none|ignore)')
101
+ }
102
+
103
+ if (this.#lock === 'none' && this.#bundle === 'none') {
104
+ throw new RangeError('lock=none requires bundle=(add|replace|load|ignore)')
105
+ }
106
+ }
107
+
108
+ loadConfig(json) {
109
+ const {
110
+ scope = this.#scope,
111
+ lock = this.#lock,
112
+ bundle = this.#bundle,
113
+ debug = this.#debug,
114
+ ...rest
115
+ } = JSON.parse(json)
116
+ assert.equal(Object.keys(rest).length, 0)
117
+ this.#scope = scope
118
+ this.#lock = lock
119
+ this.#bundle = bundle
120
+ this.#debug = debug
121
+ this.#checkInvariants()
122
+
123
+ try {
124
+ if (this.#env.scope !== undefined) assert.equal(this.#scope, this.#env.scope)
125
+ if (this.#env.lock !== undefined) assert.equal(this.#lock, this.#env.lock)
126
+ if (this.#env.bundle !== undefined) assert.equal(this.#bundle, this.#env.bundle)
127
+ if (this.#env.debug !== undefined) assert.equal(this.#debug, envDebugBool(this.#env.debug))
128
+ } catch (cause) {
129
+ throw new Error('Flags/env can not override stasis.config.json', { cause })
130
+ }
131
+ }
132
+
133
+ get bundleFile() {
134
+ return this.#bundleFile
135
+ }
136
+
137
+ get debug() {
138
+ return this.#debug
139
+ }
140
+
141
+ get values() {
142
+ return { scope: this.#scope }
143
+ }
144
+
145
+ get bundle() {
146
+ return this.#bundle !== 'none' && this.#bundle !== 'ignore'
147
+ }
148
+
149
+ get bundleMode() {
150
+ return this.#bundle
151
+ }
152
+
153
+ get ignoreBundle() {
154
+ return this.#bundle === 'ignore'
155
+ }
156
+
157
+ get writeBundle() {
158
+ return this.#bundle === 'add' || this.#bundle === 'replace'
159
+ }
160
+
161
+ get replaceBundle() {
162
+ return this.#bundle === 'replace'
163
+ }
164
+
165
+ get loadBundle() {
166
+ return this.#bundle === 'load'
167
+ }
168
+
169
+ get full() {
170
+ return this.#scope === 'full'
171
+ }
172
+
173
+ get frozen() {
174
+ return this.#lock === 'frozen'
175
+ }
176
+
177
+ get useLockfile() {
178
+ return this.#lock !== 'none' && this.#lock !== 'ignore'
179
+ }
180
+
181
+ get ignoreLockfile() {
182
+ return this.#lock === 'ignore'
183
+ }
184
+
185
+ get writeLockfile() {
186
+ return this.#lock === 'add' || this.#lock === 'replace'
187
+ }
188
+
189
+ get replaceLockfile() {
190
+ return this.#lock === 'replace'
191
+ }
192
+
193
+ get scope() {
194
+ return this.#scope
195
+ }
196
+
197
+ get lock() {
198
+ return this.#lock
199
+ }
200
+
201
+ get json() {
202
+ const data = { scope: this.#scope, lock: this.#lock, bundle: this.#bundle }
203
+ return JSON.stringify(data, undefined, 2) + '\n'
204
+ }
205
+ }
package/src/esbuild.js ADDED
@@ -0,0 +1,56 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { isUtf8 } from 'node:buffer'
3
+ import { extname } from 'node:path'
4
+ import { pathToFileURL } from 'node:url'
5
+ import assert from 'node:assert/strict'
6
+
7
+ import { State } from './state.js'
8
+
9
+ const state = State.instance
10
+ assert.ok(state, 'Stasis preload is not active')
11
+
12
+ export class StasisEsbuild {
13
+ #seen = new Set()
14
+
15
+ get name() {
16
+ return 'stasis'
17
+ }
18
+
19
+ setup = ({ onResolve, onLoad, resolve }) => {
20
+ onResolve({ filter: /$/, namespace: 'file' }, async ({ path: specifier, ...args }) => {
21
+ // Recurse with a synthetic namespace so esbuild's default resolver runs without re-entering us
22
+ const res = await resolve(specifier, { ...args, namespace: 'stasis' })
23
+ if (res.errors.length > 0) return res
24
+ assert.equal(res.warnings.length, 0)
25
+ assert.equal(res.suffix, '')
26
+
27
+ const isEntry = !args.importer
28
+ if (res.namespace === 'file' && !res.external) {
29
+ if (!isEntry) {
30
+ const parentURL = pathToFileURL(args.importer).toString()
31
+ const url = pathToFileURL(res.path).toString()
32
+ state.addImport(parentURL, specifier, url)
33
+ }
34
+ }
35
+
36
+ return { ...res, pluginData: { ...res.pluginData, isEntry } }
37
+ })
38
+
39
+ onLoad({ filter: /$/, namespace: 'file' }, async ({ path, pluginData, with: attrs }) => {
40
+ assert.equal(Object.keys(attrs).length, 0) // TODO: 'with' statements
41
+ const source = await readFile(path)
42
+ const isBinary = !isUtf8(source)
43
+
44
+ if (!this.#seen.has(path)) {
45
+ this.#seen.add(path)
46
+ state.addFile(pathToFileURL(path).toString(), { source, isEntry: pluginData?.isEntry, isBinary })
47
+ }
48
+
49
+ if (isBinary) return { contents: source, loader: 'binary' }
50
+
51
+ const loader = extname(path).replace(/^\.[cm]?/, '')
52
+ assert(['js', 'ts', 'jsx', 'tsx', 'json'].includes(loader))
53
+ return { contents: source, loader }
54
+ })
55
+ }
56
+ }
package/src/loader.js ADDED
@@ -0,0 +1,132 @@
1
+ import { registerHooks, findPackageJSON, isBuiltin } from 'node:module'
2
+ import { basename, dirname, extname, resolve as resolvePath } from 'node:path'
3
+ import { readFileSync } from 'node:fs'
4
+ import { fileURLToPath, pathToFileURL } from 'node:url'
5
+ import assert from 'node:assert/strict'
6
+
7
+ import { State } from './state.js'
8
+
9
+ // Warning: only covers code imports, not file reads
10
+
11
+ let state
12
+ let saved = false
13
+
14
+ function initState(root) {
15
+ state = new State(root, { preload: true })
16
+
17
+ const save = () => {
18
+ if (saved) return
19
+ saved = true
20
+ state.write()
21
+ }
22
+
23
+ process.on('beforeExit', save)
24
+ process.on('exit', save)
25
+ }
26
+
27
+ function load(url, context, nextLoad) {
28
+ assert.equal(typeof url, 'string')
29
+
30
+ if (url.startsWith('node:')) {
31
+ const result = nextLoad(url, context)
32
+ assert.equal(result.format, 'builtin')
33
+ return result
34
+ }
35
+
36
+ if (state && state.config.loadBundle) {
37
+ // node_modules scope only bundles node_modules sources; non-tracked files are read
38
+ // from disk through Node's default loader (state.getFile is never called for them).
39
+ if (!state.config.full && !url.includes('/node_modules/')) {
40
+ return nextLoad(url, context)
41
+ }
42
+ const { source, format } = state.getFile(url)
43
+ // context.format may be null when the resolve chain didn't set it (e.g. node_modules
44
+ // scope, where non-nm parents go through nextResolve and Node's default doesn't
45
+ // populate format for the load hook). Only cross-check when the chain provided one.
46
+ // TODO: format here is sourced from the bundle's `formats` map, which the lockfile
47
+ // does not currently cover; a tampered bundle can flip module<->commonjs for a
48
+ // hash-valid file. Derive format from extension + module.type (recorded in the
49
+ // lockfile) and reject the bundle's claim when it disagrees.
50
+ if (context.format != null) assert.equal(format, context.format)
51
+ assert.ok(['module', 'commonjs', 'json'].includes(format))
52
+ return { source, format, shortCircuit: true }
53
+ }
54
+
55
+ const result = nextLoad(url, context)
56
+ let { source } = result
57
+ const { format } = result
58
+ assert.notEqual(format, 'builtin')
59
+
60
+ // Node's CJS loader may return source=null and read from disk itself; capture it for the bundle
61
+ if (source == null && format === 'commonjs') source = readFileSync(fileURLToPath(url))
62
+
63
+ const isEntry = !state
64
+ if (!state) {
65
+ const pkg = findPackageJSON(url)
66
+ assert.equal(basename(pkg), 'package.json')
67
+ initState(dirname(pkg))
68
+ }
69
+
70
+ assert.equal(saved, false)
71
+ state.addFile(url, { source, format, isEntry })
72
+
73
+ return result
74
+ }
75
+
76
+ function resolve(specifier, context, nextResolve) {
77
+ if (isBuiltin(specifier)) {
78
+ const res = nextResolve(specifier)
79
+ assert.equal(res.url, specifier.startsWith('node:') ? specifier : `node:${specifier}`)
80
+ return res
81
+ }
82
+
83
+ if (context.importAttributes !== undefined) {
84
+ const expectedAttrs = Object.create(null) // Saving is not supported yet, so we ensure expected ones
85
+ if (extname(specifier) === '.json' && context.importAttributes?.type) expectedAttrs.type = 'json'
86
+ assert.deepStrictEqual(context.importAttributes, expectedAttrs)
87
+ }
88
+
89
+ const { parentURL, conditions, importAttributes } = context
90
+
91
+ // In full-scope load mode, the entry point is served from the bundle without touching
92
+ // disk, so state must be initialised before nextResolve runs. node_modules scope reads
93
+ // non-nm sources (including the entry) from disk, so state is initialised later in load().
94
+ if (!state && !parentURL && process.env.EXODUS_STASIS_BUNDLE === 'load'
95
+ && process.env.EXODUS_STASIS_SCOPE !== 'node_modules') {
96
+ initState(process.cwd())
97
+ }
98
+
99
+ if (state && state.config.loadBundle) {
100
+ // node_modules scope defers resolution of non-nm parents to Node. Relative imports
101
+ // between non-nm sources then resolve to local files, and bare specifiers resolve via
102
+ // the on-disk node_modules layout -- but the load() hook still serves the resulting
103
+ // node_modules target from the bundle, so package contents need not be on disk.
104
+ if (!state.config.full && !parentURL?.includes('/node_modules/')) {
105
+ return nextResolve(specifier)
106
+ }
107
+ // Bare specifier with a parent is the regular ESM import: look up the recorded
108
+ // import map. Otherwise we have either a pre-resolved file URL (CLI entries and
109
+ // ESM-to-CJS translator require() targets both arrive that way) or a bare entry
110
+ // specifier we anchor to cwd -- either way, skip nextResolve so the entry file
111
+ // doesn't need to exist on disk.
112
+ if (parentURL && !specifier.startsWith('file:')) {
113
+ const { url, format } = state.getImport(parentURL, specifier, { conditions, importAttributes })
114
+ return { url, format, importAttributes: undefined, shortCircuit: true }
115
+ }
116
+ const url = specifier.startsWith('file:')
117
+ ? specifier
118
+ : pathToFileURL(resolvePath(process.cwd(), specifier)).toString()
119
+ if (!parentURL && state.config.full) state.assertEntry(url)
120
+ return { url, format: state.getFormat(url), importAttributes: undefined, shortCircuit: true }
121
+ }
122
+
123
+ const res = nextResolve(specifier)
124
+ const { url, format } = res
125
+
126
+ assert.equal(res.importAttributes, undefined) // unsupported yet
127
+ if (parentURL) assert.ok(state)
128
+ if (state) state.addImport(parentURL, specifier, url, { conditions, format, importAttributes })
129
+ return res
130
+ }
131
+
132
+ registerHooks({ load, resolve })
@@ -0,0 +1,80 @@
1
+ import { assert, fileSetToObject, fromEntries, sortPaths } from './util.js'
2
+
3
+ const VERSION = 0
4
+
5
+ const normalize = ({ name, version, files }) =>
6
+ ({ name, version, files: fromEntries(Object.entries(files)) })
7
+
8
+ export class Lockfile {
9
+ static VERSION = VERSION
10
+
11
+ version = VERSION
12
+ config
13
+ entries
14
+ modules
15
+
16
+ constructor({ config = { scope: 'full' }, entries, modules } = {}) {
17
+ assert(['node_modules', 'full'].includes(config.scope))
18
+ this.config = config
19
+ this.entries = entries ?? new Set()
20
+ this.modules = modules ?? new Map()
21
+ }
22
+
23
+ static parse(text) {
24
+ const json = JSON.parse(text)
25
+ assert(json.version === VERSION)
26
+ assert(['node_modules', 'full'].includes(json.config?.scope))
27
+
28
+ const full = json.config.scope === 'full'
29
+ assert(!!json.entries === full)
30
+ assert(!!json.sources === full)
31
+ assert(json.modules)
32
+
33
+ const modules = new Map()
34
+ for (const [dir, info] of Object.entries(json.modules)) {
35
+ assert(dir.includes('node_modules'))
36
+ assert(info?.name && info.version && info.files)
37
+ modules.set(dir, normalize(info))
38
+ }
39
+
40
+ let entries = new Set()
41
+ if (full) {
42
+ assert(Array.isArray(json.entries))
43
+ entries = new Set(json.entries)
44
+ for (const [dir, info] of Object.entries(json.sources)) {
45
+ assert(!dir.includes('node_modules'))
46
+ modules.set(dir, normalize(info))
47
+ }
48
+ }
49
+
50
+ for (const [dir, { files }] of modules) {
51
+ assert(!dir.startsWith('..'))
52
+ assert(files)
53
+ for (const name of Object.keys(files)) {
54
+ assert(!name.startsWith('..'))
55
+ }
56
+ }
57
+
58
+ return new Lockfile({ config: json.config, entries, modules })
59
+ }
60
+
61
+ serialize() {
62
+ const entries = fileSetToObject(this.entries)
63
+ const modules = []
64
+ const sources = []
65
+ for (const [dir, { name, version, files }] of this.modules) {
66
+ const inNodeModules = dir.includes('node_modules')
67
+ if (inNodeModules) assert(name && version && files)
68
+ const type = inNodeModules ? modules : sources
69
+ const sorted = fromEntries(Object.entries(files).toSorted((a, b) => sortPaths(a[0], b[0])))
70
+ type.push([dir, { name, version, files: sorted }])
71
+ }
72
+ modules.sort((a, b) => sortPaths(a[0], b[0]))
73
+ sources.sort((a, b) => sortPaths(a[0], b[0]))
74
+
75
+ const store = { version: this.version, config: this.config }
76
+ if (this.config.scope === 'full') Object.assign(store, { entries, sources: fromEntries(sources) })
77
+ Object.assign(store, { modules: fromEntries(modules) })
78
+ return JSON.stringify(store, undefined, 2) + '\n'
79
+ }
80
+ }
package/src/prune.js ADDED
@@ -0,0 +1,162 @@
1
+ import assert from 'node:assert/strict'
2
+ import {
3
+ existsSync,
4
+ readFileSync,
5
+ readdirSync,
6
+ rmdirSync,
7
+ unlinkSync,
8
+ } from 'node:fs'
9
+ import { basename, dirname, join, relative, resolve, sep } from 'node:path'
10
+
11
+ import { Lockfile } from './lockfile.js'
12
+ import { sha512integrity } from './state.util.js' // also runs the posix-sep assertion
13
+
14
+ const LOCKFILE = 'stasis.lock.json'
15
+
16
+ function assertGlobalVirtualStoreDisabled() {
17
+ // pnpm exports its settings as `npm_config_*` env vars to hooks and
18
+ // lifecycle scripts. When the global virtual store is enabled, node_modules
19
+ // is dominated by symlinks into a shared store; pruning would walk past
20
+ // most files (we skip symlinks) and the lockfile-vs-disk comparison stops
21
+ // being meaningful. Reject before touching anything.
22
+ const env = process.env.npm_config_enable_global_virtual_store
23
+ if (env !== undefined && env !== 'false' && env !== '') {
24
+ throw new Error(
25
+ `stasis prune: enableGlobalVirtualStore must be false, got ${JSON.stringify(env)}`,
26
+ )
27
+ }
28
+ }
29
+
30
+ function loadLockfile(root) {
31
+ const path = join(root, LOCKFILE)
32
+ assert.ok(existsSync(path), `stasis prune: ${LOCKFILE} not found at ${path}`)
33
+ return Lockfile.parse(readFileSync(path, 'utf8'))
34
+ }
35
+
36
+ function buildExpected(lockfile) {
37
+ const expected = new Map()
38
+ const knownDirs = new Set()
39
+ for (const [dir, { files }] of lockfile.modules) {
40
+ if (!dir.includes('node_modules')) continue // workspace sources, not pnpm-managed
41
+ knownDirs.add(dir)
42
+ for (const [rel, hash] of Object.entries(files)) {
43
+ expected.set(`${dir}/${rel}`, hash)
44
+ }
45
+ }
46
+ return { expected, knownDirs }
47
+ }
48
+
49
+ function* walkFiles(nodeModules) {
50
+ if (!existsSync(nodeModules)) return
51
+ const stack = [nodeModules]
52
+ while (stack.length > 0) {
53
+ const dir = stack.pop()
54
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
55
+ const full = join(dir, entry.name)
56
+ // Skip symlinks: pnpm's flat node_modules symlinks into .pnpm; the file
57
+ // bytes are reachable via the symlink targets which we also walk
58
+ // directly when we descend into .pnpm.
59
+ if (entry.isSymbolicLink()) continue
60
+ if (entry.isDirectory()) stack.push(full)
61
+ else if (entry.isFile()) yield full
62
+ }
63
+ }
64
+ }
65
+
66
+ function pruneEmptyDirs(dir, stopAt) {
67
+ while (dir.startsWith(stopAt) && dir !== stopAt && existsSync(dir)) {
68
+ let entries
69
+ try {
70
+ entries = readdirSync(dir)
71
+ } catch {
72
+ return
73
+ }
74
+ if (entries.length > 0) return
75
+ try {
76
+ rmdirSync(dir)
77
+ } catch {
78
+ return
79
+ }
80
+ dir = dirname(dir)
81
+ }
82
+ }
83
+
84
+ export function prune({ root = process.cwd() } = {}) {
85
+ assertGlobalVirtualStoreDisabled()
86
+ root = resolve(root)
87
+ const nodeModules = join(root, 'node_modules')
88
+ const lockfile = loadLockfile(root)
89
+ const { expected, knownDirs } = buildExpected(lockfile)
90
+
91
+ // Plan first, mutate later: walk the tree, validate every tracked file,
92
+ // collect the deletion list, and verify nothing in the lockfile is
93
+ // missing. Bail with a thrown error before touching disk on any failure.
94
+ const toRemove = []
95
+ const validated = []
96
+ const kept = []
97
+ const seen = new Set()
98
+ const mismatches = []
99
+
100
+ for (const full of walkFiles(nodeModules)) {
101
+ const rel = relative(root, full)
102
+ const expectedHash = expected.get(rel)
103
+ if (expectedHash !== undefined) {
104
+ seen.add(rel)
105
+ const actual = sha512integrity(readFileSync(full))
106
+ if (actual === expectedHash) {
107
+ validated.push(rel)
108
+ kept.push(rel)
109
+ } else {
110
+ mismatches.push({ rel, expected: expectedHash, actual })
111
+ }
112
+ continue
113
+ }
114
+
115
+ // Package.json files are kept (without hash validation, since they may
116
+ // not be enumerated in the lockfile) only when their containing dir is
117
+ // a module recorded in the lockfile. Stray package.jsons under
118
+ // node_modules dirs that the lockfile doesn't know about get pruned
119
+ // along with the rest of those modules.
120
+ if (basename(rel) === 'package.json' && knownDirs.has(dirname(rel))) {
121
+ kept.push(rel)
122
+ continue
123
+ }
124
+
125
+ // Defense-in-depth: the path must resolve inside node_modules (no
126
+ // symlink escape, no `..` traversal) before we queue it for deletion.
127
+ assert.ok(
128
+ full === nodeModules || full.startsWith(`${nodeModules}${sep}`),
129
+ `refusing to remove path outside node_modules: ${full}`,
130
+ )
131
+ toRemove.push(full)
132
+ }
133
+
134
+ const missing = []
135
+ for (const [rel] of expected) {
136
+ if (!seen.has(rel)) missing.push(rel)
137
+ }
138
+
139
+ if (mismatches.length > 0) {
140
+ const lines = mismatches.map(({ rel, expected: exp, actual }) =>
141
+ ` ${rel}: expected ${exp}, got ${actual}`).join('\n')
142
+ throw new Error(`stasis prune: hash mismatch for ${mismatches.length} file(s):\n${lines}`)
143
+ }
144
+ if (missing.length > 0) {
145
+ const sample = missing.slice(0, 5).join(', ')
146
+ const suffix = missing.length > 5 ? `, ... (${missing.length} total)` : ''
147
+ throw new Error(`stasis prune: files listed in lockfile are missing on disk: ${sample}${suffix}`)
148
+ }
149
+
150
+ const removed = []
151
+ const touchedDirs = new Set()
152
+ for (const full of toRemove) {
153
+ unlinkSync(full)
154
+ removed.push(relative(root, full))
155
+ touchedDirs.add(dirname(full))
156
+ }
157
+
158
+ const sortedDirs = [...touchedDirs].toSorted((a, b) => b.length - a.length)
159
+ for (const d of sortedDirs) pruneEmptyDirs(d, nodeModules)
160
+
161
+ return { removed, validated, kept }
162
+ }