@autor3search/javascript 0.2.0

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 (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +500 -0
  3. package/bin/autor3search-javascript.js +4 -0
  4. package/package.json +50 -0
  5. package/src/adapters/bench/index.js +31 -0
  6. package/src/adapters/bench/vitest.js +128 -0
  7. package/src/adapters/driver-child.js +72 -0
  8. package/src/adapters/driver-hooks.js +18 -0
  9. package/src/adapters/driver.js +192 -0
  10. package/src/adapters/gates/index.js +64 -0
  11. package/src/adapters/gates/lint.js +46 -0
  12. package/src/adapters/gates/test.js +27 -0
  13. package/src/adapters/gates/typecheck.js +98 -0
  14. package/src/adapters/gates/util.js +45 -0
  15. package/src/adapters/vitest-shim.js +51 -0
  16. package/src/bench/parse.js +120 -0
  17. package/src/bench/set.js +101 -0
  18. package/src/bench/stats.js +443 -0
  19. package/src/cli/cmd-baseline.js +136 -0
  20. package/src/cli/cmd-doctor.js +38 -0
  21. package/src/cli/cmd-eval.js +231 -0
  22. package/src/cli/cmd-init.js +136 -0
  23. package/src/cli/cmd-profile.js +38 -0
  24. package/src/cli/cmd-report.js +96 -0
  25. package/src/cli/cmd-status.js +68 -0
  26. package/src/cli/cmd-stop.js +98 -0
  27. package/src/cli/cmd-version.js +31 -0
  28. package/src/cli/context.js +98 -0
  29. package/src/cli/main.js +62 -0
  30. package/src/config.js +209 -0
  31. package/src/discover.js +239 -0
  32. package/src/doctor.js +282 -0
  33. package/src/duration.js +75 -0
  34. package/src/freeze.js +234 -0
  35. package/src/gitx.js +115 -0
  36. package/src/measure.js +127 -0
  37. package/src/pipeline.js +391 -0
  38. package/src/profile.js +100 -0
  39. package/src/results.js +124 -0
  40. package/src/runner.js +198 -0
  41. package/src/scope.js +92 -0
  42. package/src/state/index.js +312 -0
  43. package/src/state/lock.js +189 -0
  44. package/src/state/stop.js +56 -0
  45. package/src/verdict.js +214 -0
  46. package/templates/program.md +264 -0
package/src/doctor.js ADDED
@@ -0,0 +1,282 @@
1
+ /**
2
+ * Checks whether this machine can measure benchmarks reliably.
3
+ *
4
+ * Every check is INFORMATIONAL. Numbers are only as good as the machine
5
+ * producing them, and a thermally throttled laptop on battery produces noise
6
+ * dressed as data — but whether that is acceptable is the human's call, not
7
+ * the harness's, so doctor reports and never blocks.
8
+ */
9
+ import { execFile } from 'node:child_process'
10
+ import { readFile, stat, statfs } from 'node:fs/promises'
11
+ import { cpus, loadavg, platform } from 'node:os'
12
+ import { resolve as resolvePath } from 'node:path'
13
+ import { promisify } from 'node:util'
14
+ import * as gitx from './gitx.js'
15
+ import { vitestBin } from './adapters/bench/vitest.js'
16
+
17
+ const exec = promisify(execFile)
18
+
19
+ export const SEVERITY = { OK: 0, WARN: 1, FAIL: 2, NA: -1 }
20
+
21
+ /** The Node floor: parseArgs, module.register and --heap-prof all need it. */
22
+ const MIN_NODE_MAJOR = 20
23
+
24
+ /**
25
+ * Runs every check. Each one is individually guarded, so an unfamiliar
26
+ * platform or a missing tool degrades one line rather than the whole report.
27
+ *
28
+ * @param {string} dir the repository root, or a directory inside it
29
+ * @returns {Promise<{name: string, detail: string, severity: number}[]>}
30
+ */
31
+ export async function check(dir) {
32
+ // `-C` defaults to '.', so every check is given an ABSOLUTE directory once,
33
+ // up front: the git checks shell out with it as cwd, the disk check statfs's
34
+ // it, and the vitest check reports the path it looked in — a relative one
35
+ // would make that message meaningless. Resolving here keeps every check
36
+ // consistent regardless of what the caller passed.
37
+ const absDir = resolvePath(dir)
38
+
39
+ const platformCheck =
40
+ platform() === 'darwin'
41
+ ? ['power', checkDarwin]
42
+ : platform() === 'linux'
43
+ ? ['cpu governor', checkLinux]
44
+ : platform() === 'win32'
45
+ ? ['platform', checkWindows]
46
+ : null
47
+
48
+ const checks = [
49
+ ['node', checkNode],
50
+ ['git', checkGit],
51
+ ['git repo', () => checkGitRepo(absDir)],
52
+ ['cpu', checkCpu],
53
+ ['load', checkLoad],
54
+ ['vitest', () => checkVitest(absDir)],
55
+ ['heap hint', checkHeapHint],
56
+ platformCheck,
57
+ ['disk', () => checkDisk(absDir)],
58
+ ].filter(Boolean)
59
+
60
+ const findings = []
61
+ for (const [name, fn] of checks) {
62
+ try {
63
+ findings.push(await fn())
64
+ } catch (err) {
65
+ findings.push({ name, detail: `check failed: ${err.message}`, severity: SEVERITY.NA })
66
+ }
67
+ }
68
+ return findings
69
+ }
70
+
71
+ function checkNode() {
72
+ const major = Number(process.versions.node.split('.')[0])
73
+ return major >= MIN_NODE_MAJOR
74
+ ? { name: 'node', detail: `node ${process.versions.node}`, severity: SEVERITY.OK }
75
+ : {
76
+ name: 'node',
77
+ detail: `node ${process.versions.node} is too old, need >= ${MIN_NODE_MAJOR}`,
78
+ severity: SEVERITY.FAIL,
79
+ }
80
+ }
81
+
82
+ async function checkGit() {
83
+ try {
84
+ const { stdout } = await exec('git', ['--version'])
85
+ return { name: 'git', detail: stdout.trim(), severity: SEVERITY.OK }
86
+ } catch {
87
+ return { name: 'git', detail: 'git not found on PATH', severity: SEVERITY.FAIL }
88
+ }
89
+ }
90
+
91
+ async function checkGitRepo(dir) {
92
+ try {
93
+ return { name: 'git repo', detail: await gitx.root(dir), severity: SEVERITY.OK }
94
+ } catch {
95
+ return { name: 'git repo', detail: `${dir} is not inside a git repository`, severity: SEVERITY.FAIL }
96
+ }
97
+ }
98
+
99
+ function checkCpu() {
100
+ const n = cpus().length
101
+ return {
102
+ name: 'cpu',
103
+ detail: `${n} logical core(s) — ${cpus()[0]?.model ?? 'unknown model'}`,
104
+ severity: n >= 2 ? SEVERITY.OK : SEVERITY.WARN,
105
+ }
106
+ }
107
+
108
+ function checkLoad() {
109
+ const [one] = loadavg()
110
+ const perCore = one / Math.max(1, cpus().length)
111
+ return {
112
+ name: 'load',
113
+ detail:
114
+ `1-minute load ${one.toFixed(2)} (${perCore.toFixed(2)} per core)` +
115
+ (perCore > 0.5 ? ' — other work on this machine will show up as measurement noise' : ''),
116
+ severity: perCore > 0.5 ? SEVERITY.WARN : SEVERITY.OK,
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Resolves vitest from the MEASURED repository, not this harness's own
122
+ * node_modules — a repo with no vitest installed must not look fine just
123
+ * because the harness happens to depend on it for its own tests.
124
+ */
125
+ async function checkVitest(dir) {
126
+ // Check the exact file the adapter spawns, not a module resolution of it.
127
+ // require.resolve('vitest/vitest.mjs') honours the package `exports` map,
128
+ // and Vitest 2 exposes a `./*` wildcard there while Vitest 3 and 4 do not —
129
+ // so the resolution answered "not installed" for every Vitest 3/4 repository
130
+ // even though the file is present and benchmarks measure fine. doctor exists
131
+ // to predict whether measurement will work, so it must ask what measurement
132
+ // asks.
133
+ const bin = vitestBin(dir)
134
+ const present = await stat(bin).then(
135
+ (st) => st.isFile(),
136
+ () => false,
137
+ )
138
+ return present
139
+ ? { name: 'vitest', detail: 'vitest resolves in this repository', severity: SEVERITY.OK }
140
+ : {
141
+ name: 'vitest',
142
+ detail: `vitest is not installed here — benchmarks cannot be measured until it is (looked for ${bin})`,
143
+ severity: SEVERITY.WARN,
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Windows is not a supported platform, and the reason is the stop path.
149
+ *
150
+ * 448 of 458 tests pass there, so most of the harness works — but Node cannot
151
+ * deliver SIGINT to a child process group on Windows the way it does on POSIX,
152
+ * so an interrupted `eval` does not reach the ABORTED path: it exits with a
153
+ * null code instead of 2, and the run may leave a claim behind. An unattended
154
+ * harness that cannot be reliably stopped is the wrong thing to be quiet about,
155
+ * so this says it up front rather than at 3am.
156
+ *
157
+ * WSL reports linux and is unaffected.
158
+ */
159
+ function checkWindows() {
160
+ return {
161
+ name: 'platform',
162
+ detail:
163
+ 'Windows is not a supported platform: an interrupted eval does not reach the ABORTED path, so a run ' +
164
+ 'may not stop cleanly and can leave its claim behind. Measurement itself works. Use WSL for a supported setup.',
165
+ severity: SEVERITY.WARN,
166
+ }
167
+ }
168
+
169
+ function checkHeapHint() {
170
+ // Reported up front so a run does not discover mid-flight that the hint is
171
+ // unavailable, which would otherwise look like the benchmark allocating
172
+ // nothing. The driver always spawns its own child with --expose-gc, so
173
+ // this is OK regardless of whether gc() happens to be exposed in THIS
174
+ // process — but say so explicitly rather than implying it was probed here.
175
+ return {
176
+ name: 'heap hint',
177
+ detail: 'the driver spawns its own --expose-gc child, so the bytes/op hint should be available',
178
+ severity: SEVERITY.OK,
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Runs a command and returns its trimmed stdout, or null if it failed —
184
+ * missing binary, non-zero exit, or unrecognized flag. Every macOS-specific
185
+ * probe below tolerates null so one absent tool degrades one note, not the
186
+ * whole finding.
187
+ */
188
+ async function tryExec(cmd, args) {
189
+ try {
190
+ const { stdout } = await exec(cmd, args)
191
+ return stdout
192
+ } catch {
193
+ return null
194
+ }
195
+ }
196
+
197
+ async function checkDarwin() {
198
+ const notes = []
199
+ let severity = SEVERITY.OK
200
+
201
+ const batt = await tryExec('pmset', ['-g', 'batt'])
202
+ if (batt === null) {
203
+ notes.push('pmset unavailable — power source unknown')
204
+ } else if (/Battery Power/.test(batt)) {
205
+ notes.push('running on battery — macOS clocks down aggressively')
206
+ severity = SEVERITY.WARN
207
+ } else {
208
+ notes.push('on AC power')
209
+ }
210
+
211
+ const gen = await tryExec('pmset', ['-g'])
212
+ if (gen === null) {
213
+ notes.push('Low Power Mode unknown')
214
+ } else {
215
+ const m = gen.match(/lowpowermode\s+(\d+)/i)
216
+ if (m && m[1] !== '0') {
217
+ notes.push('Low Power Mode is on')
218
+ severity = SEVERITY.WARN
219
+ }
220
+ }
221
+
222
+ // `machdep.xcpm.cpu_thermal_level` is Intel-only (xcpm = Intel's power
223
+ // manager) and does not exist on Apple Silicon, where sysctl exits
224
+ // non-zero with "unknown oid". `pmset -g therm` works on both, but macOS
225
+ // only starts recording these fields once a thermal EVENT has actually
226
+ // occurred since boot — a fresh boot legitimately prints
227
+ // "No thermal warning level has been recorded", which means "nothing to
228
+ // report", not "unknown". Anything else unrecognized is reported as such
229
+ // rather than silently assumed fine.
230
+ const therm = await tryExec('pmset', ['-g', 'therm'])
231
+ if (therm === null) {
232
+ notes.push('thermal state unknown (pmset -g therm unavailable)')
233
+ } else {
234
+ const limit = therm.match(/CPU_Speed_Limit\s*=\s*(\d+)/)
235
+ if (limit) {
236
+ const pct = Number(limit[1])
237
+ if (pct < 100) {
238
+ notes.push(`CPU speed limited to ${pct}% by thermal pressure`)
239
+ severity = SEVERITY.WARN
240
+ } else {
241
+ notes.push('no thermal throttling')
242
+ }
243
+ } else if (/No thermal warning level has been recorded/i.test(therm)) {
244
+ notes.push('no thermal event recorded since boot')
245
+ } else {
246
+ notes.push('thermal state format not recognized on this machine')
247
+ }
248
+ }
249
+
250
+ notes.push('P/E core scheduling makes macOS numbers jump; a quiet Linux box gives cleaner results')
251
+ return { name: 'power', detail: notes.join('; '), severity }
252
+ }
253
+
254
+ async function checkLinux() {
255
+ const governor = await readFile('/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor', 'utf8').catch(() => null)
256
+ const noTurbo = await readFile('/sys/devices/system/cpu/intel_pstate/no_turbo', 'utf8').catch(() => null)
257
+ const notes = []
258
+ let severity = SEVERITY.OK
259
+ if (governor === null) {
260
+ notes.push('cpufreq governor not readable')
261
+ } else if (governor.trim() !== 'performance') {
262
+ notes.push(`cpufreq governor is "${governor.trim()}" — "performance" gives steadier numbers`)
263
+ severity = SEVERITY.WARN
264
+ } else {
265
+ notes.push('cpufreq governor is "performance"')
266
+ }
267
+ if (noTurbo !== null && noTurbo.trim() === '0') {
268
+ notes.push('turbo boost is enabled — clock varies with temperature')
269
+ severity = SEVERITY.WARN
270
+ }
271
+ return { name: 'cpu governor', detail: notes.join('; '), severity }
272
+ }
273
+
274
+ async function checkDisk(dir) {
275
+ const stats = await statfs(dir)
276
+ const freeGb = (stats.bavail * stats.bsize) / 1024 ** 3
277
+ return {
278
+ name: 'disk',
279
+ detail: `${freeGb.toFixed(1)} GB free`,
280
+ severity: freeGb < 2 ? SEVERITY.WARN : SEVERITY.OK,
281
+ }
282
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Duration parsing, used for `timeout` in the run configuration. Values are
3
+ * written as a number and a unit ("1s", "500ms", "15m"), optionally
4
+ * compounded ("1m30s") — the notation most performance tooling already uses,
5
+ * so there is nothing new to learn here.
6
+ */
7
+
8
+ /** Milliseconds per unit. */
9
+ const UNITS = {
10
+ ns: 1e-6,
11
+ us: 1e-3,
12
+ µs: 1e-3,
13
+ ms: 1,
14
+ s: 1000,
15
+ m: 60_000,
16
+ h: 3_600_000,
17
+ }
18
+
19
+ /** One `<number><unit>` term, e.g. "1h", "30.5s". */
20
+ const TERM = /(\d+(?:\.\d+)?)(ns|us|µs|ms|s|m|h)/gy
21
+
22
+ /**
23
+ * Parses a duration into milliseconds. Fractional values and compound terms
24
+ * ("1m30s") are both accepted; a negative or unitless value is not.
25
+ *
26
+ * @param {string} s
27
+ * @returns {number} milliseconds
28
+ * @throws {Error} when s is not a duration
29
+ */
30
+ export function parseDuration(s) {
31
+ const bad = () => new Error(`${JSON.stringify(s)} is not a duration (want e.g. "500ms", "1s", "15m")`)
32
+ if (typeof s !== 'string' || s.length === 0) throw bad()
33
+
34
+ TERM.lastIndex = 0
35
+ let total = 0
36
+ let terms = 0
37
+ let lastIndex = 0
38
+ let match
39
+ while ((match = TERM.exec(s)) !== null) {
40
+ total += Number(match[1]) * UNITS[match[2]]
41
+ terms++
42
+ lastIndex = TERM.lastIndex
43
+ }
44
+ // The sticky flag anchors each term to the end of the previous one, so a
45
+ // full match means lastIndex reached the end of the string. Anything left
46
+ // over — a leading sign, a stray unit, trailing text — means this was not a
47
+ // duration, and must not be silently accepted as its parseable prefix.
48
+ if (terms === 0 || lastIndex !== s.length) throw bad()
49
+ return total
50
+ }
51
+
52
+ /**
53
+ * Renders milliseconds back into the same notation parseDuration accepts.
54
+ * Used only for human-facing output.
55
+ *
56
+ * @param {number} ms
57
+ * @returns {string}
58
+ */
59
+ export function formatDuration(ms) {
60
+ if (ms < 1000) return `${round(ms)}ms`
61
+ const totalSeconds = ms / 1000
62
+ const hours = Math.floor(totalSeconds / 3600)
63
+ const minutes = Math.floor((totalSeconds % 3600) / 60)
64
+ const seconds = round(totalSeconds % 60)
65
+ let out = ''
66
+ if (hours) out += `${hours}h`
67
+ if (minutes) out += `${minutes}m`
68
+ if (seconds || out === '') out += `${seconds}s`
69
+ return out
70
+ }
71
+
72
+ /** Rounds to at most three decimal places, dropping a trailing ".0". */
73
+ function round(n) {
74
+ return Number(n.toFixed(3))
75
+ }
package/src/freeze.js ADDED
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Snapshots test and benchmark files at baseline time and restores them
3
+ * before every evaluation, so an agent cannot weaken its own success
4
+ * criteria — or rewrite the benchmark that defines the metric.
5
+ *
6
+ * Every path constant here is relative to the run's OUT-OF-TREE state
7
+ * directory, never to the repository root. The frozen store and its manifest
8
+ * are part of what the score depends on, so they must live where the agent
9
+ * being measured cannot reach them; a caller joining these onto the
10
+ * repository root would silently reintroduce that hole.
11
+ */
12
+ import { createHash } from 'node:crypto'
13
+ import { lstat, mkdir, readFile, writeFile } from 'node:fs/promises'
14
+ import { dirname, join, posix, isAbsolute } from 'node:path'
15
+
16
+ export const STORE_DIR = 'frozen'
17
+ export const MANIFEST_PATH = 'frozen/manifest.json'
18
+
19
+ /** `.code` on an error raised by a symlink anywhere along a frozen path. */
20
+ export const ERR_SYMLINK = 'A3S_SYMLINK'
21
+
22
+ /** `.code` on an error raised when a golden copy no longer matches its hash. */
23
+ export const ERR_STORE_TAMPERED = 'A3S_STORE_TAMPERED'
24
+
25
+ /**
26
+ * `.code` on an error raised by a HARD link at a frozen path.
27
+ *
28
+ * A hard link defeats the symlink defence entirely: `lstat().isSymbolicLink()`
29
+ * is false for one, so the path looks like an ordinary regular file. Writing
30
+ * to it writes to every other name for that inode — so an agent can point a
31
+ * frozen path at any file it may write and have `restore` clobber it with
32
+ * test source. That is an uncontrolled write outside the repository, which is
33
+ * exactly what this module exists to prevent.
34
+ */
35
+ export const ERR_HARD_LINK = 'A3S_HARD_LINK'
36
+
37
+ /**
38
+ * Copies each file into storeDir and records its hash.
39
+ *
40
+ * @param {string} repoRoot
41
+ * @param {string} storeDir
42
+ * @param {string[]} files repo-relative paths
43
+ * @returns {Promise<{files: Record<string, string>}>}
44
+ */
45
+ export async function snapshot(repoRoot, storeDir, files) {
46
+ const manifest = { files: {} }
47
+ for (const rel of [...files].sort()) {
48
+ const src = safeJoin(repoRoot, rel, 'snapshot')
49
+ await refuseSymlink(repoRoot, rel, 'snapshot', 'in the repository')
50
+ // The store is harness-owned, but a directory left behind by an earlier
51
+ // attempt under the same tag is not necessarily pristine: refuse to write
52
+ // the golden copy through a link there either.
53
+ await refuseSymlink(storeDir, rel, 'snapshot', 'inside the frozen store')
54
+
55
+ const content = await readFile(src)
56
+ const dst = safeJoin(storeDir, rel, 'snapshot')
57
+ await mkdir(dirname(dst), { recursive: true })
58
+ await refuseHardLink(dst, rel, 'snapshot', 'inside the frozen store')
59
+ await writeFile(dst, content)
60
+ manifest.files[rel] = hash(content)
61
+ }
62
+ return manifest
63
+ }
64
+
65
+ /**
66
+ * Rewrites every frozen file in the working tree from the store, recreating
67
+ * files the agent deleted. Returns the paths it changed, sorted.
68
+ *
69
+ * Both sides are checked against the hash recorded at baseline, and the
70
+ * working tree is examined BEFORE the store is read. That ordering is what
71
+ * makes the common case — an eval where the agent touched no frozen file —
72
+ * cost one read per file instead of two: the destination already hashes to
73
+ * the manifest value, so the golden copy is never opened at all.
74
+ *
75
+ * @returns {Promise<string[]>}
76
+ */
77
+ export async function restore(repoRoot, storeDir, manifest) {
78
+ const changed = []
79
+ for (const rel of sortedPaths(manifest)) {
80
+ // Before any read or write: readFile follows links exactly as writeFile
81
+ // does, so this has to come first, or the check would read THROUGH a link
82
+ // and conclude the file was fine.
83
+ await refuseSymlink(repoRoot, rel, 'restore', 'in the repository')
84
+ const dst = safeJoin(repoRoot, rel, 'restore')
85
+
86
+ const current = await readFile(dst).catch(() => null)
87
+ if (current && hash(current) === manifest.files[rel]) continue
88
+
89
+ await refuseSymlink(storeDir, rel, 'restore', 'inside the frozen store')
90
+ const src = safeJoin(storeDir, rel, 'restore')
91
+ const golden = await readFile(src)
92
+ const got = hash(golden)
93
+ if (got !== manifest.files[rel]) {
94
+ throw tagged(
95
+ `restore ${rel}: frozen store copy does not match the hash recorded at baseline ` +
96
+ `(store hashes to ${got}, manifest records ${manifest.files[rel]})`,
97
+ ERR_STORE_TAMPERED,
98
+ )
99
+ }
100
+
101
+ await mkdir(dirname(dst), { recursive: true })
102
+ await refuseHardLink(dst, rel, 'restore', 'in the repository')
103
+ await writeFile(dst, golden)
104
+ changed.push(rel)
105
+ }
106
+ return changed
107
+ }
108
+
109
+ /**
110
+ * Reports which frozen files currently differ from the baseline. A deleted
111
+ * file counts as changed, and so does one reached through a symlink — that is
112
+ * at least as suspicious as a deletion, and is reported rather than followed.
113
+ *
114
+ * @returns {Promise<string[]>}
115
+ */
116
+ export async function verify(repoRoot, manifest) {
117
+ const changed = []
118
+ for (const rel of sortedPaths(manifest)) {
119
+ const linked = await symlinkComponent(repoRoot, rel)
120
+ if (linked) {
121
+ changed.push(rel)
122
+ continue
123
+ }
124
+ const content = await readFile(safeJoin(repoRoot, rel, 'verify')).catch(() => null)
125
+ if (content === null || hash(content) !== manifest.files[rel]) changed.push(rel)
126
+ }
127
+ return changed
128
+ }
129
+
130
+ /** Writes the manifest as indented JSON. */
131
+ export async function saveManifest(path, manifest) {
132
+ await mkdir(dirname(path), { recursive: true })
133
+ await writeFile(path, `${JSON.stringify(manifest, null, 2)}\n`)
134
+ }
135
+
136
+ /** Reads a manifest written by saveManifest. */
137
+ export async function loadManifest(path) {
138
+ let text
139
+ try {
140
+ text = await readFile(path, 'utf8')
141
+ } catch (err) {
142
+ throw new Error(`read manifest ${path}: ${err.message}`, { cause: err })
143
+ }
144
+ let parsed
145
+ try {
146
+ parsed = JSON.parse(text)
147
+ } catch (err) {
148
+ throw new Error(`parse manifest ${path}: ${err.message}`, { cause: err })
149
+ }
150
+ return { files: parsed.files ?? {} }
151
+ }
152
+
153
+ const sortedPaths = (manifest) => Object.keys(manifest.files).sort()
154
+
155
+ const hash = (buffer) => createHash('sha256').update(buffer).digest('hex')
156
+
157
+ /**
158
+ * Joins rel onto root, rejecting anything that would escape it. Manifest
159
+ * entries come from a JSON file on disk, so they are untrusted input:
160
+ * `restore` writes through them before every evaluation.
161
+ */
162
+ function safeJoin(root, rel, op) {
163
+ if (isAbsolute(rel)) throw new Error(`${op} ${rel}: frozen path must be relative`)
164
+ const clean = posix.normalize(rel.split('\\').join('/'))
165
+ if (clean === '..' || clean.startsWith('../')) {
166
+ throw new Error(`${op} ${rel}: frozen path escapes the repository root`)
167
+ }
168
+ return join(root, clean)
169
+ }
170
+
171
+ /**
172
+ * Throws when any component of rel, beneath root, is a symlink.
173
+ *
174
+ * Checking only the FINAL component is not enough, and that is the hole this
175
+ * closes: readFile and writeFile resolve the whole path, so replacing a
176
+ * parent DIRECTORY with a link redirects the write just as effectively as
177
+ * replacing the file itself, landing the frozen content outside the
178
+ * repository. An lstat on the file then reports a perfectly ordinary regular
179
+ * file, because the link was already followed to get there.
180
+ *
181
+ * `root` itself is deliberately not examined: a repository legitimately
182
+ * reached through a symlinked ancestor — macOS's /tmp, a home directory on a
183
+ * linked volume — is not tampering, and refusing to work there would break
184
+ * ordinary setups.
185
+ */
186
+ async function refuseSymlink(root, rel, op, where) {
187
+ const linked = await symlinkComponent(root, rel)
188
+ if (!linked) return
189
+ throw tagged(
190
+ `${op} ${rel}: ${linked} is a symlink ${where}; refusing to read or write through it, ` +
191
+ `which could reach a file outside the repository`,
192
+ ERR_SYMLINK,
193
+ )
194
+ }
195
+
196
+ /** The first component of rel, beneath root, that is a symlink — or null. */
197
+ async function symlinkComponent(root, rel) {
198
+ const parts = posix.normalize(rel.split('\\').join('/')).split('/').filter((p) => p !== '' && p !== '.')
199
+ let path = root
200
+ for (const [i, part] of parts.entries()) {
201
+ path = join(path, part)
202
+ const stats = await lstat(path).catch(() => null)
203
+ if (stats?.isSymbolicLink()) return parts.slice(0, i + 1).join('/')
204
+ }
205
+ return null
206
+ }
207
+
208
+ /**
209
+ * Throws when the destination is a hard link — a file with more than one name.
210
+ *
211
+ * Checked immediately before every write, on both the working tree and the
212
+ * store. A regular file in a checkout has exactly one link; more than one
213
+ * means writing here also writes somewhere else, potentially outside the
214
+ * repository. Only an existing regular file is examined: a path that does not
215
+ * exist yet cannot be linked, and a directory's link count is unrelated.
216
+ */
217
+ async function refuseHardLink(path, rel, op, where) {
218
+ const stats = await lstat(path).catch(() => null)
219
+ if (stats?.isFile() && stats.nlink > 1) {
220
+ throw tagged(
221
+ `${op} ${rel}: ${rel} has ${stats.nlink} names (a hard link) ${where}; refusing to write through ` +
222
+ `it, because that would also overwrite the other name(s) for this file — possibly outside the ` +
223
+ `repository. Replace it with a regular file and rerun.`,
224
+ ERR_HARD_LINK,
225
+ )
226
+ }
227
+ }
228
+
229
+ /** Builds an Error carrying a stable `.code` the pipeline branches on. */
230
+ function tagged(message, code) {
231
+ const err = new Error(message)
232
+ err.code = code
233
+ return err
234
+ }