@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/gitx.js ADDED
@@ -0,0 +1,115 @@
1
+ /**
2
+ * The git commands autor3search-javascript needs.
3
+ *
4
+ * Stdout and stderr are captured separately so that stderr chatter on an
5
+ * otherwise successful command — git-lfs filter warnings, advice.* hints,
6
+ * locale warnings, a user's own hooks — is never parsed as part of the
7
+ * result. Stderr appears in the error message only when the command fails.
8
+ */
9
+ import { execFile } from 'node:child_process'
10
+ import { promisify } from 'node:util'
11
+
12
+ const exec = promisify(execFile)
13
+
14
+ /** Runs a git subcommand in dir and returns its trimmed stdout. */
15
+ async function git(dir, ...args) {
16
+ try {
17
+ const { stdout } = await exec('git', args, { cwd: dir, maxBuffer: 64 * 1024 * 1024 })
18
+ return stdout.trim()
19
+ } catch (err) {
20
+ const stderr = (err.stderr ?? '').trim()
21
+ throw new Error(`git ${args.join(' ')}: ${err.message}${stderr ? `\n${stderr}` : ''}`, { cause: err })
22
+ }
23
+ }
24
+
25
+ /** The repository root containing dir. */
26
+ export const root = (dir) => git(dir, 'rev-parse', '--show-toplevel')
27
+
28
+ /** The short hash of HEAD. */
29
+ export const headCommit = (dir) => git(dir, 'rev-parse', '--short=7', 'HEAD')
30
+
31
+ /**
32
+ * The first line of HEAD's commit message. `stop --force` prints this to
33
+ * identify the commit an abandoned experiment left behind, so only the
34
+ * subject is wanted — never the body or its trailers.
35
+ */
36
+ export const headSubject = (dir) => git(dir, 'log', '-1', '--format=%s')
37
+
38
+ /** The checked-out branch name. */
39
+ export const currentBranch = (dir) => git(dir, 'rev-parse', '--abbrev-ref', 'HEAD')
40
+
41
+ /** Creates and checks out a branch at HEAD. */
42
+ export const createBranch = (dir, name) => git(dir, 'checkout', '-b', name).then(() => undefined)
43
+
44
+ /** Switches the working tree to an existing branch or other ref. */
45
+ export const checkout = (dir, ref) => git(dir, 'checkout', ref).then(() => undefined)
46
+
47
+ /**
48
+ * Force-deletes a local branch. Used to undo a run branch left behind by a
49
+ * `baseline` attempt that failed partway through, so a retry under the same
50
+ * name is not permanently blocked.
51
+ */
52
+ export const deleteBranch = (dir, name) => git(dir, 'branch', '-D', name).then(() => undefined)
53
+
54
+ /** Checks commit out into a detached worktree at path. */
55
+ export const addWorktree = (repoDir, path, commit) =>
56
+ git(repoDir, 'worktree', 'add', '--detach', path, commit).then(() => undefined)
57
+
58
+ /**
59
+ * Moves an existing worktree to commit, detached. Used to advance the pinned
60
+ * baseline worktree after a KEEP: re-pointing an existing worktree with a
61
+ * plain checkout has less failure surface than removing and re-adding it,
62
+ * which would re-register it in the main repository's metadata rather than
63
+ * just moving HEAD. -f discards stray changes in the target — nothing should
64
+ * ever modify the pinned worktree, but checking out over a dirty tree without
65
+ * it would fail instead of re-pointing.
66
+ */
67
+ export const checkoutDetached = (dir, commit) =>
68
+ git(dir, 'checkout', '-f', '--detach', commit).then(() => undefined)
69
+
70
+ /**
71
+ * Deletes a worktree previously created by addWorktree.
72
+ *
73
+ * --force silently discards uncommitted and untracked changes in the target.
74
+ * git still refuses to remove the main working tree or an unregistered path,
75
+ * but callers must only ever point this at a worktree the harness itself
76
+ * created — never at a path a human might have unsaved work in.
77
+ */
78
+ export const removeWorktree = (repoDir, path) =>
79
+ git(repoDir, 'worktree', 'remove', '--force', path).then(() => undefined)
80
+
81
+ /** Reports whether a local branch exists. */
82
+ export async function branchExists(dir, name) {
83
+ try {
84
+ await git(dir, 'show-ref', '--verify', '--quiet', `refs/heads/${name}`)
85
+ return true
86
+ } catch {
87
+ return false
88
+ }
89
+ }
90
+
91
+ /** Reports whether the working tree has no changes. */
92
+ export async function isClean(dir) {
93
+ return (await git(dir, 'status', '--porcelain')) === ''
94
+ }
95
+
96
+ /**
97
+ * Lists repo-relative paths modified since commit, including untracked files
98
+ * that are not gitignored, sorted and deduplicated.
99
+ *
100
+ * Both git calls use -z. Without it, git quotes and octal-escapes any path
101
+ * containing non-ASCII bytes, quotes or backslashes (e.g. "src/caf\303\251.js"),
102
+ * which would then fail scope matching and let an out-of-scope edit through.
103
+ * -z output is NUL-separated and not terminated, so it is split explicitly.
104
+ */
105
+ export async function changedSince(dir, commit) {
106
+ const tracked = await git(dir, 'diff', '--name-only', '-z', commit)
107
+ const untracked = await git(dir, 'ls-files', '-z', '--others', '--exclude-standard')
108
+ const seen = new Set()
109
+ for (const block of [tracked, untracked]) {
110
+ for (const entry of block.split('\0')) {
111
+ if (entry !== '') seen.add(entry)
112
+ }
113
+ }
114
+ return [...seen].sort()
115
+ }
package/src/measure.js ADDED
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Collects observations from a baseline and a candidate tree in INTERLEAVED
3
+ * order.
4
+ *
5
+ * This is the core measurement discipline. Comparing a candidate measured now
6
+ * against a baseline measured minutes ago attributes CPU thermal drift,
7
+ * frequency scaling and background load to the code change. Alternating the
8
+ * two sides within a single session cancels that, because both sides
9
+ * experience the same conditions.
10
+ */
11
+ import { BenchSet } from './bench/set.js'
12
+ import { getBenchRunner } from './adapters/bench/index.js'
13
+ import { measureHeap } from './adapters/driver.js'
14
+
15
+ /**
16
+ * Runs base and cand alternately for `rounds` measured rounds, accumulating
17
+ * their observations. With `warmup`, one extra leading round is run and
18
+ * discarded, absorbing first-touch effects such as cold caches and JIT
19
+ * tier-up.
20
+ *
21
+ * The two sides SWAP ORDER on every round — base,cand then cand,base — rather
22
+ * than always running base first. Alternating rounds alone cancels drift
23
+ * BETWEEN rounds, but a fixed order within each round leaves a systematic
24
+ * offset: the candidate would then always be measured one slot later than the
25
+ * baseline, so any drift monotonic across a round (a CPU still ramping toward
26
+ * its thermal steady state, a background job starting mid-run) lands on the
27
+ * candidate in the same direction every single time. Averaging over rounds
28
+ * does not remove it, because it is not noise — it is a constant bias, and it
29
+ * shifts the score the KEEP threshold is compared against.
30
+ *
31
+ * An odd number of measured rounds cannot be split evenly and leaves one
32
+ * round's worth of that offset behind; an even count cancels it exactly.
33
+ *
34
+ * @param {{rounds: number, warmup: boolean, base: (round: number) => Promise<BenchSet>, cand: (round: number) => Promise<BenchSet>, signal?: AbortSignal}} opts
35
+ * @returns {Promise<{baseSet: BenchSet, candSet: BenchSet}>}
36
+ */
37
+ export async function interleave({ rounds, warmup, base, cand, signal }) {
38
+ if (rounds < 2) throw new Error(`need at least 2 measured rounds, got ${rounds}`)
39
+
40
+ const baseSet = new BenchSet()
41
+ const candSet = new BenchSet()
42
+ const total = warmup ? rounds + 1 : rounds
43
+
44
+ for (let i = 0; i < total; i++) {
45
+ signal?.throwIfAborted()
46
+ const baseFirst = i % 2 === 0
47
+ const [first, second] = baseFirst ? [base, cand] : [cand, base]
48
+ const [firstLabel, secondLabel] = baseFirst ? ['baseline', 'candidate'] : ['candidate', 'baseline']
49
+
50
+ const firstSet = await labelled(first, i, firstLabel)
51
+ signal?.throwIfAborted()
52
+ const secondSet = await labelled(second, i, secondLabel)
53
+
54
+ if (warmup && i === 0) continue
55
+ baseSet.add(baseFirst ? firstSet : secondSet)
56
+ candSet.add(baseFirst ? secondSet : firstSet)
57
+ }
58
+ return { baseSet, candSet }
59
+ }
60
+
61
+ /** Runs one side's round, naming which side and which round on failure. */
62
+ async function labelled(fn, round, label) {
63
+ try {
64
+ return await fn(round)
65
+ } catch (err) {
66
+ throw new Error(`${label} round ${round}: ${err.message}`, { cause: err })
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Measures two worktrees for real, wiring the configured bench runner and,
72
+ * when enabled, the heap-delta hint.
73
+ *
74
+ * The hint is folded into the SAME round as the timing so it gets the same
75
+ * number of observations and therefore a real p-value. It is still never
76
+ * scored — see src/pipeline.js.
77
+ *
78
+ * `benchmarks` selects by exact name, same as the bench adapter itself
79
+ * (src/adapters/bench/vitest.js): an empty array is the valid, ordinary
80
+ * "measure everything discovered" case, not an error. There is no regexp
81
+ * `pattern` here — Vitest's own benchmark name filter does not work, so
82
+ * selection happens after parsing, by exact name.
83
+ *
84
+ * @param {object} opts
85
+ * @returns {Promise<{baseSet: BenchSet, candSet: BenchSet}>}
86
+ */
87
+ export async function measure(opts) {
88
+ if (!Array.isArray(opts.benchmarks)) {
89
+ throw new Error('measure: benchmarks must be an array (empty selects every discovered benchmark)')
90
+ }
91
+ const runner = getBenchRunner(opts.runner)
92
+
93
+ const roundFn = (dir) => async () => {
94
+ const set = await runner.run(dir, {
95
+ benchmarks: opts.benchmarks,
96
+ // The same file list both sides get, so neither is measured under a
97
+ // different workload than the other.
98
+ benchFiles: opts.benchFiles ?? [],
99
+ timeoutMs: opts.timeoutMs,
100
+ env: opts.env,
101
+ log: opts.log,
102
+ signal: opts.signal,
103
+ })
104
+ if (opts.heapHint) {
105
+ set.add(
106
+ await measureHeap(dir, {
107
+ benchFiles: opts.benchFiles ?? [],
108
+ benchmarks: opts.benchmarks,
109
+ iterations: opts.heapIterations ?? 1000,
110
+ timeoutMs: opts.timeoutMs,
111
+ env: opts.env,
112
+ log: opts.log,
113
+ signal: opts.signal,
114
+ }),
115
+ )
116
+ }
117
+ return set
118
+ }
119
+
120
+ return interleave({
121
+ rounds: opts.rounds,
122
+ warmup: opts.warmup ?? true,
123
+ base: roundFn(opts.baseDir),
124
+ cand: roundFn(opts.candDir),
125
+ signal: opts.signal,
126
+ })
127
+ }
@@ -0,0 +1,391 @@
1
+ /**
2
+ * Runs one full evaluation: gate correctness, measure the candidate against
3
+ * the pinned baseline, score it.
4
+ *
5
+ * It lives here rather than in the command layer so it is testable without a
6
+ * process boundary — `eval` itself handles only flags, output formatting and
7
+ * the exit code.
8
+ *
9
+ * CONTRACT: evalOnce RETURNS a terminal verdict for every gate outcome and
10
+ * every completed measurement. A THROWN error means the harness itself
11
+ * malfunctioned (I/O, git, a malformed baseline), never that the candidate
12
+ * was rejected. Callers treat those two cases completely differently.
13
+ */
14
+ import { createHash } from 'node:crypto'
15
+ import { readFile } from 'node:fs/promises'
16
+ import { join } from 'node:path'
17
+ import { CONFIG_PATH } from './config.js'
18
+ import { UNIT_BYTES, UNIT_TIME } from './bench/set.js'
19
+ import { compareAll, geoMean } from './bench/stats.js'
20
+ import { benchFilesFor, frozenFiles } from './discover.js'
21
+ import * as freeze from './freeze.js'
22
+ import * as gitx from './gitx.js'
23
+ import { measure } from './measure.js'
24
+ import { RESULTS_PATH } from './results.js'
25
+ import { runGates } from './adapters/gates/index.js'
26
+ import { createMatcher } from './scope.js'
27
+ import { WORKTREE_NAME, BASELINE_FILE, linkNodeModules, saveBaseline } from './state/index.js'
28
+ import { REASON, STATUS, decide, gate } from './verdict.js'
29
+ import { parseDuration } from './duration.js'
30
+
31
+ /**
32
+ * The harness-owned scratch log inside the repository root. Subprocess output
33
+ * that would otherwise flood an unattended agent's context is written here
34
+ * rather than to stdout. `init` gitignores it, and it is not part of the score.
35
+ */
36
+ export const RUN_LOG_NAME = 'run.log'
37
+
38
+ /**
39
+ * Files whose modification is rejected regardless of scope. Changing a
40
+ * dependency is a supply-chain decision a human makes, not something an
41
+ * unattended overnight loop decides — and a swapped dependency changes WHAT
42
+ * is measured, not just how fast it runs. The default scope matches root
43
+ * files, so this cannot be left to the scope patterns.
44
+ */
45
+ export const DEPENDENCY_FILES = new Set([
46
+ 'package.json',
47
+ 'package-lock.json',
48
+ 'npm-shrinkwrap.json',
49
+ 'yarn.lock',
50
+ 'pnpm-lock.yaml',
51
+ 'bun.lock',
52
+ 'bun.lockb',
53
+ ])
54
+
55
+ /**
56
+ * Vitest/Vite config filenames whose modification is rejected regardless of
57
+ * scope, for a different reason than DEPENDENCY_FILES: these are loaded by
58
+ * the bench runner ITSELF (`vitest bench --run --root=<dir>`), so an
59
+ * ordinary, in-scope, committed config file can redirect what a frozen
60
+ * benchmark imports — or stub out the code path it measures entirely —
61
+ * without the frozen benchmark file itself changing by even one byte. The
62
+ * default scope (`["**"]`) admits a root config file exactly like any other
63
+ * source file, so this cannot be left to the scope patterns any more than a
64
+ * dependency file can.
65
+ *
66
+ * The name/extension list is exhaustive for Vitest 2.1.9, verified directly
67
+ * against its own resolution table
68
+ * (node_modules/vitest/dist/chunks/constants.*.js: CONFIG_NAMES,
69
+ * CONFIG_EXTENSIONS, WORKSPACES_NAMES, WORKSPACES_EXTENSIONS) rather than
70
+ * guessed from documentation:
71
+ * - `vitest.config` / `vite.config` (Vitest loads Vite's own config too),
72
+ * each with .js .mjs .cjs .ts .mts .cts
73
+ * - `vitest.workspace` / `vitest.projects` — the latter is an alternate
74
+ * workspace name Vitest accepts that is easy to miss because the two
75
+ * names are documented as synonyms nowhere obvious — each with those six
76
+ * extensions plus .json
77
+ */
78
+ const CONFIG_NAMES = ['vitest.config', 'vite.config']
79
+ const CONFIG_EXTS = ['.js', '.mjs', '.cjs', '.ts', '.mts', '.cts']
80
+ const WORKSPACE_NAMES = ['vitest.workspace', 'vitest.projects']
81
+ const WORKSPACE_EXTS = [...CONFIG_EXTS, '.json']
82
+
83
+ export const TOOLCHAIN_CONFIG_FILES = new Set([
84
+ ...CONFIG_NAMES.flatMap((name) => CONFIG_EXTS.map((ext) => `${name}${ext}`)),
85
+ ...WORKSPACE_NAMES.flatMap((name) => WORKSPACE_EXTS.map((ext) => `${name}${ext}`)),
86
+ ])
87
+
88
+ /** Which gate failure maps to which status and reason. */
89
+ const GATE_VERDICT = {
90
+ typecheck: { status: STATUS.CRASH, reason: REASON.TYPECHECK },
91
+ lint: { status: STATUS.FAIL, reason: REASON.LINT },
92
+ test: { status: STATUS.FAIL, reason: REASON.TESTS },
93
+ }
94
+
95
+ /**
96
+ * @param {{root: string, stateDir: string, cfg: object, baseline: object, log?: object, signal?: AbortSignal}} o
97
+ * @returns {Promise<{result: object, measurements: {time: object[], bytes: object[]|null}|null}>}
98
+ */
99
+ export async function evalOnce(o) {
100
+ const timeoutMs = parseDuration(o.cfg.timeout)
101
+
102
+ // ---- 1. Scope --------------------------------------------------------
103
+ // Checked before anything is restored or built, so an out-of-scope edit is
104
+ // reported as itself rather than as a build error.
105
+ //
106
+ // Deliberately diffs against baseline.commit — the FROZEN, never-advancing
107
+ // anchor — and NOT measureCommit, which moves after every KEEP. Anchoring
108
+ // here means the gate re-validates the FULL accumulated diff on every eval.
109
+ // Anchoring it to the advancing pointer instead would give an out-of-scope
110
+ // edit exactly one eval in which to be caught: past that single check it
111
+ // would become part of the "already accepted" state and never be looked at
112
+ // again.
113
+ const changed = await gitx.changedSince(o.root, o.baseline.commit)
114
+ const matcher = createMatcher(o.cfg.scope)
115
+ const frozenSet = new Set(await frozenFiles(o.root, o.cfg.unfreeze))
116
+
117
+ for (const rel of changed) {
118
+ if (DEPENDENCY_FILES.has(rel)) {
119
+ return terminal(
120
+ gate(
121
+ STATUS.FAIL,
122
+ REASON.SCOPE,
123
+ `${rel} may not be modified: dependency changes are a human decision, not an autonomous one`,
124
+ ),
125
+ )
126
+ }
127
+ if (TOOLCHAIN_CONFIG_FILES.has(rel)) {
128
+ return terminal(
129
+ gate(
130
+ STATUS.FAIL,
131
+ REASON.SCOPE,
132
+ `${rel} may not be modified: it is loaded by the bench runner itself and can redirect what a ` +
133
+ `benchmark measures without changing the frozen benchmark file at all`,
134
+ ),
135
+ )
136
+ }
137
+ // Harness output, plus the human-owned config, which is integrity-checked
138
+ // below rather than scope-checked.
139
+ if (rel === RESULTS_PATH || rel === RUN_LOG_NAME || rel === CONFIG_PATH) continue
140
+ // Frozen files are handled by restore, not by the scope gate.
141
+ if (frozenSet.has(rel)) continue
142
+ if (!matcher.match(rel)) {
143
+ return terminal(
144
+ gate(STATUS.FAIL, REASON.SCOPE, `${rel} is outside the allowed scope ${JSON.stringify(o.cfg.scope)}`),
145
+ )
146
+ }
147
+ }
148
+
149
+ // ---- 1b. Config integrity -------------------------------------------
150
+ // config.yaml lives in the repo because humans own it, which means the
151
+ // agent can reach it. Raising max_regress_pct or deleting entries from the
152
+ // benchmark set would defeat the guard, so the file is hashed at baseline
153
+ // and any change fails the run.
154
+ const configSha = await sha256File(join(o.root, CONFIG_PATH))
155
+ if (configSha !== o.baseline.configSha256) {
156
+ return terminal(
157
+ gate(
158
+ STATUS.FAIL,
159
+ REASON.CONFIG_CHANGED,
160
+ `${CONFIG_PATH} changed since baseline — scoring rules are fixed for a run; revert it, or start ` +
161
+ `a new run with 'autor3search-javascript baseline'`,
162
+ ),
163
+ )
164
+ }
165
+
166
+ // ---- 2. Restore frozen tests and benchmarks --------------------------
167
+ const manifest = await freeze.loadManifest(join(o.stateDir, freeze.MANIFEST_PATH))
168
+ let restored
169
+ try {
170
+ restored = await freeze.restore(o.root, join(o.stateDir, freeze.STORE_DIR), manifest)
171
+ } catch (err) {
172
+ // A frozen file replaced by a symlink or a hard link is TAMPERING, not a
173
+ // harness malfunction: report it as a verdict so the run gets a
174
+ // results.tsv row and an actionable message, rather than aborting with no
175
+ // signal at all.
176
+ if (err.code === freeze.ERR_HARD_LINK) {
177
+ return terminal(
178
+ gate(
179
+ STATUS.FAIL,
180
+ REASON.HARDLINK_SWAP,
181
+ `${err.message} — a frozen file must have exactly one name; a hard link would let a restore ` +
182
+ `write outside the repository`,
183
+ ),
184
+ )
185
+ }
186
+ if (err.code === freeze.ERR_SYMLINK) {
187
+ return terminal(
188
+ gate(
189
+ STATUS.FAIL,
190
+ REASON.SYMLINK_SWAP,
191
+ `${err.message} — a frozen file, and every directory on the way to it, must remain a regular ` +
192
+ `file and real directories; restore them and rerun`,
193
+ ),
194
+ )
195
+ }
196
+ // A golden copy that no longer matches its recorded hash means the store
197
+ // itself was rewritten. Unlike a symlink this cannot be undone by fixing
198
+ // the working tree, because the reference copy is the thing that was lost.
199
+ if (err.code === freeze.ERR_STORE_TAMPERED) {
200
+ return terminal(
201
+ gate(
202
+ STATUS.FAIL,
203
+ REASON.FROZEN_TAMPERED,
204
+ `${err.message} — the frozen copy this run scores against was modified, so its tests can no ` +
205
+ `longer be trusted. Start a fresh run with 'autor3search-javascript baseline'.`,
206
+ ),
207
+ )
208
+ }
209
+ throw err
210
+ }
211
+ if (restored.length > 0) {
212
+ o.log?.write(`restored ${restored.length} frozen file(s): ${restored.join(', ')}\n`)
213
+ }
214
+
215
+ // ---- 2b. Frozen-set integrity, in BOTH directions --------------------
216
+ // Restore only rewrites files it froze, and the scope gate skips every
217
+ // frozen path, so without this an agent could ADD a brand-new test or bench
218
+ // file — an easier benchmark, or one shadowing a frozen one — and neither
219
+ // gate would notice.
220
+ //
221
+ // The reverse direction matters just as much and is easier to miss: a file
222
+ // restore just rewrote should always be visible to the walker again, so a
223
+ // manifest entry MISSING from what is present means the walk could not
224
+ // reach it. A structural change to the tree can hide a frozen file from
225
+ // discovery while leaving it nominally restored, and the run would then
226
+ // score against a benchmark set that no longer runs.
227
+ const present = new Set(await frozenFiles(o.root, o.cfg.unfreeze))
228
+ const added = [...present].filter((rel) => !(rel in manifest.files)).sort()
229
+ if (added.length > 0) {
230
+ return terminal(
231
+ gate(
232
+ STATUS.FAIL,
233
+ REASON.NEW_TEST_FILE,
234
+ `test or benchmark files not present at baseline: ${added.join(', ')} — the benchmark set is ` +
235
+ `frozen; add them before running 'autor3search-javascript baseline', or list them in config unfreeze`,
236
+ ),
237
+ )
238
+ }
239
+ const missing = Object.keys(manifest.files).filter((rel) => !present.has(rel)).sort()
240
+ if (missing.length > 0) {
241
+ return terminal(
242
+ gate(
243
+ STATUS.FAIL,
244
+ REASON.MISSING_TEST_FILE,
245
+ `frozen files are no longer discoverable in the working tree: ${missing.join(', ')} — they were ` +
246
+ `restored, but the walk that finds them cannot reach them, so they would not run. Check for a ` +
247
+ `directory on their path that was replaced, renamed, or moved under a skipped name.`,
248
+ ),
249
+ )
250
+ }
251
+
252
+ // ---- 3-5. Correctness gates -----------------------------------------
253
+ const outcomes = await runGates(o.root, {
254
+ modes: o.cfg.gates,
255
+ scope: o.cfg.scope,
256
+ timeoutMs,
257
+ log: o.log,
258
+ signal: o.signal,
259
+ })
260
+ // An abort during the gate phase is an ABORT, not a gate failure. Without
261
+ // this the killed subprocess would look like a failing test, producing a
262
+ // FAIL verdict and a results.tsv row for an experiment nobody measured.
263
+ if (o.signal?.aborted) throw new Error('aborted during the correctness gates')
264
+ for (const outcome of outcomes) {
265
+ if (outcome.skipped) o.log?.write(`gate ${outcome.name} skipped: ${outcome.skipped}\n`)
266
+ if (outcome.ok) continue
267
+ if (outcome.timedOut) {
268
+ return terminal(gate(STATUS.CRASH, REASON.TIMEOUT, `${outcome.name} gate timed out after ${o.cfg.timeout}`))
269
+ }
270
+ const { status, reason } = GATE_VERDICT[outcome.name]
271
+ return terminal(gate(status, reason, outcome.detail))
272
+ }
273
+
274
+ // ---- 5b. Baseline worktree integrity --------------------------------
275
+ // An agent could edit the pinned baseline worktree in place to make the
276
+ // BASELINE slow, after which every candidate "improves" and every
277
+ // experiment returns KEEP without optimizing anything.
278
+ //
279
+ // Compares against measureCommit, not commit: the worktree is re-pointed
280
+ // after every KEEP, so comparing against the frozen anchor would fire
281
+ // spuriously on the very next eval after any KEEP.
282
+ //
283
+ // This is a DETECTION, and only a partial one. The state directory lives
284
+ // outside the repository, but the agent runs as the same OS user, so
285
+ // nothing stops it editing the worktree in place — this catches that only
286
+ // if HEAD itself moves. Treat it as catching accidental clobbering and a
287
+ // careless tamper, not as a guarantee.
288
+ const worktreeDir = join(o.stateDir, WORKTREE_NAME)
289
+ const worktreeHead = await gitx.headCommit(worktreeDir)
290
+ if (worktreeHead !== o.baseline.measureCommit) {
291
+ return terminal(
292
+ gate(
293
+ STATUS.FAIL,
294
+ REASON.BASELINE_TAMPERED,
295
+ `pinned baseline worktree HEAD is ${worktreeHead} but the recorded measurement commit is ` +
296
+ `${o.baseline.measureCommit} — the worktree no longer matches the baseline and this run's ` +
297
+ `measurements cannot be trusted. Start a fresh run with 'autor3search-javascript baseline'.`,
298
+ ),
299
+ )
300
+ }
301
+
302
+ // ---- 6. Measure ------------------------------------------------------
303
+ // Self-heals a worktree that lost its node_modules link — the target
304
+ // moved, someone deleted it — rather than failing every eval after the
305
+ // first. See linkNodeModules in src/state/index.js.
306
+ await linkNodeModules(o.root, worktreeDir)
307
+ let baseSet, candSet
308
+ try {
309
+ ;({ baseSet, candSet } = await measure({
310
+ runner: o.cfg.runner,
311
+ baseDir: worktreeDir,
312
+ candDir: o.root,
313
+ benchmarks: o.baseline.benchmarks,
314
+ rounds: o.cfg.count,
315
+ warmup: true,
316
+ timeoutMs,
317
+ heapHint: o.cfg.heapHint,
318
+ benchFiles: await benchFilesFor(o.root, o.baseline.benchmarks),
319
+ log: o.log,
320
+ signal: o.signal,
321
+ }))
322
+ } catch (err) {
323
+ // An ABORT is not a measurement failure. The bench adapter reports an
324
+ // aborted round as a timeout (both set `timedOut` on the runner result),
325
+ // so without this check a human pressing Ctrl+C or running `stop --force`
326
+ // would get CRASH/measurement_failed instead of ABORTED — and a
327
+ // results.tsv row would be written for an experiment that was never
328
+ // measured. Rethrow so the command layer's abort handler owns it.
329
+ if (o.signal?.aborted) throw err
330
+ return terminal(gate(STATUS.CRASH, REASON.MEASUREMENT, err.message))
331
+ }
332
+
333
+ // ---- 7. Score --------------------------------------------------------
334
+ // Time is the scored metric: any failure here — including a benchmark that
335
+ // vanished from the candidate — fails the whole call, per compareAll's
336
+ // contract.
337
+ const timeDeltas = compareAll(baseSet, candSet, UNIT_TIME)
338
+ const score = geoMean(timeDeltas)
339
+
340
+ // The bytes hint is informational ONLY. compareAll is strict about a
341
+ // benchmark disappearing from one side, which is right for the scored
342
+ // metric and wrong here: a hint that could not be measured must never fail
343
+ // a real, correctly-measured experiment.
344
+ let bytesDeltas = null
345
+ try {
346
+ bytesDeltas = compareAll(baseSet, candSet, UNIT_BYTES)
347
+ } catch (err) {
348
+ o.log?.write(`bytes/op comparison unavailable, continuing without it: ${err.message}\n`)
349
+ }
350
+
351
+ const result = decide({
352
+ deltas: timeDeltas,
353
+ score,
354
+ maxRegressPct: o.cfg.maxRegressPct,
355
+ minEffectPct: o.cfg.minEffectPct,
356
+ })
357
+
358
+ // ---- 8. Advance the measurement baseline on KEEP ---------------------
359
+ // Without this, every experiment after the first kept one is measured
360
+ // against the run's ORIGINAL commit forever, so a later no-op that merely
361
+ // fails to regress an EARLIER improvement still banks as KEEP.
362
+ if (result.status === STATUS.KEEP) {
363
+ await advanceMeasurementBaseline(o, worktreeDir)
364
+ }
365
+
366
+ return { result, measurements: { time: timeDeltas, bytes: bytesDeltas } }
367
+ }
368
+
369
+ /**
370
+ * Re-points the pinned baseline worktree at the candidate's own commit and
371
+ * persists it as the new measureCommit.
372
+ *
373
+ * A failure here is thrown, not folded into the verdict: continuing to run
374
+ * experiments against a worktree that no longer agrees with the recorded
375
+ * measurement commit would silently corrupt every subsequent measurement —
376
+ * exactly the class of bug this advance exists to fix. Should it fail after
377
+ * the worktree moved but before the new commit was persisted, the NEXT eval's
378
+ * worktree-integrity check catches the mismatch and fails loudly.
379
+ */
380
+ async function advanceMeasurementBaseline(o, worktreeDir) {
381
+ const newCommit = await gitx.headCommit(o.root)
382
+ await gitx.checkoutDetached(worktreeDir, newCommit)
383
+ o.baseline.measureCommit = newCommit
384
+ await saveBaseline(join(o.stateDir, BASELINE_FILE), o.baseline)
385
+ }
386
+
387
+ const terminal = (result) => ({ result, measurements: null })
388
+
389
+ async function sha256File(path) {
390
+ return createHash('sha256').update(await readFile(path)).digest('hex')
391
+ }